From c939111563cc6271badefa19588349484986e250 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Mon, 11 May 2026 16:31:01 +0800 Subject: [PATCH 01/11] feat(harmonyos): add Sunshine clipboard sync - add HarmonyOS clipboard text sync end-to-end bridge and settings integration - wire native clipboard callbacks through ArkTS streaming session - degrade image clipboard sync explicitly on current compatible SDK - document current implementation and permission constraints --- docs/CLIPBOARD_SYNC_COMPARISON.md | 198 +++++++++++++ docs/CLIPBOARD_SYNC_IMPLEMENTATION.md | 209 ++++++++++++++ docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md | 191 +++++++++++++ entry/src/main/ets/pages/SettingsPageV2.ets | 38 +++ entry/src/main/ets/pages/StreamPage.ets | 39 +++ .../src/main/ets/service/SettingsService.ets | 4 + .../service/clipboard/ClipboardSyncConfig.ets | 161 +++++++++++ .../clipboard/ClipboardSyncService.ets | 262 ++++++++++++++++++ .../main/ets/service/jni/ClipboardBridge.ets | 127 +++++++++ .../main/ets/service/streaming/MoonBridge.ets | 2 + .../service/streaming/StreamingSession.ets | 5 + entry/src/main/module.json5 | 8 + .../main/resources/base/element/string.json | 4 + nativelib/src/main/cpp/callbacks.cpp | 87 ++++++ nativelib/src/main/cpp/callbacks.h | 2 + nativelib/src/main/cpp/moonlight-common-c | 2 +- nativelib/src/main/cpp/moonlight_bridge.cpp | 56 +++- nativelib/src/main/cpp/moonlight_bridge.h | 6 + nativelib/src/main/cpp/napi_init.cpp | 3 + 19 files changed, 1402 insertions(+), 2 deletions(-) create mode 100644 docs/CLIPBOARD_SYNC_COMPARISON.md create mode 100644 docs/CLIPBOARD_SYNC_IMPLEMENTATION.md create mode 100644 docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md create mode 100644 entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets create mode 100644 entry/src/main/ets/service/clipboard/ClipboardSyncService.ets create mode 100644 entry/src/main/ets/service/jni/ClipboardBridge.ets diff --git a/docs/CLIPBOARD_SYNC_COMPARISON.md b/docs/CLIPBOARD_SYNC_COMPARISON.md new file mode 100644 index 0000000..c9d812d --- /dev/null +++ b/docs/CLIPBOARD_SYNC_COMPARISON.md @@ -0,0 +1,198 @@ +# 安卓 vs 鸿蒙剪贴板同步对比 + +## 当前结论 + +两端的协议思路已经对齐,但当前 HarmonyOS 落地状态是: + +- **文本同步:已实现并可编译** +- **图片同步:协议与 UI 入口保留,运行时显式降级** + +换句话说,鸿蒙侧已经不是“设计稿”,而是“文本闭环版”。 + +## 核心概念对标 + +| 概念 | 安卓 (Kotlin) | 鸿蒙 (ArkTS) | +|-----|-------------|-----------| +| 配置类 | `PreferenceConfiguration` | `ClipboardSyncConfig` | +| 主服务 | `ClipboardSyncManager` | `ClipboardSyncService` | +| 桥接入口 | `MoonBridge` | `ClipboardBridge.MoonBridge` | +| 系统剪贴板 | `ClipboardManager` | `@kit.BasicServicesKit` pasteboard | +| 变化事件 | `OnPrimaryClipChangedListener` | `sysBoard.on('update', listener)` | +| 文本写入 | `setPrimaryClip()` | `setDataSync(createData(...))` | +| 图片同步 | 已实现 | 当前降级 | + +## 架构对比 + +### 安卓 + +- 监听本地剪贴板 +- 文本/图片都可发送 +- 接收主机文本/图片并写回系统剪贴板 +- 使用 `recentSentTokens + pendingSelfWrites` 处理回显 + +### 鸿蒙 + +- 监听本地剪贴板文本变化 +- 文本可发送到主机 +- 接收主机文本后写回系统剪贴板 +- 接收主机图片或本地启用图片同步时,只提示暂不支持 +- 同样使用 `recentSentTokens + pendingSelfWrites` 处理回显 + +## 图片处理差异 + +### 安卓 + +安卓侧已经有完整图片链路: + +- 从系统剪贴板中读取图片 +- 编码为 PNG +- 通过协议发送 +- 接收后通过系统机制重新暴露给应用 + +### 鸿蒙当前实现 + +鸿蒙侧当前不再假设某套图片 API 一定可用,而是明确区分: + +- **协议层:保留 PNG kind** +- **设置层:保留图片同步开关** +- **运行时:首次命中图片路径时提示不支持并停止处理** + +这样做避免了两类问题: + +1. 代码里塞入与当前 SDK 不匹配的图片接口 +2. 文档和实现脱节,导致后续维护者误判真实能力 + +## 事件监听差异 + +### 安卓 + +```kotlin +clipboard.addPrimaryClipChangedListener(listener) +clipboard.removePrimaryClipChangedListener(listener) +``` + +### 鸿蒙 + +```ts +const listener = (): void => { + void this.onPasteboardChanged() +} +sysBoard.on('update', listener) +sysBoard.off('update', listener) +``` + +## 回显抑制策略对比 + +### 两端共性 + +- 发送时生成随机 token +- 接收时先检查 token 是否为最近发送历史 +- 本地写系统剪贴板前先累加 `pendingSelfWrites` +- 监听到 update 时优先消费 self echo + +### 鸿蒙当前实现示意 + +```ts +private recentSentTokens: TokenEntry[] = [] +private pendingSelfWrites: number = 0 + +private async applyRemoteClipboardData(kind: number, token: number, payload: Uint8Array): Promise { + if (this.isHostEcho(token)) { + return + } + + if (kind === ClipboardWireFrame.KIND_PNG) { + this.warnImageSyncUnsupported('remote') + return + } + + this.pendingSelfWrites++ + const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) + sysBoard.setDataSync(pasteData) +} +``` + +## JNI / NAPI 差异 + +### 安卓 + +- Java 直接与 JNI 接口交互 +- 常见输入类型是 `byte[]` + +### 鸿蒙 + +- ArkTS 通过 NAPI 调用 C++ bridge +- 当前实现专门补了 `GetByteArrayData(...)` +- 因而既能接受 `ArrayBuffer`,也能接受 `TypedArray` +- inbound 回调通过 `napi_threadsafe_function` 回到 ArkTS + +## 权限差异 + +### 安卓 + +安卓侧更多是围绕图片 URI / 存储访问链路展开。 + +### 鸿蒙 + +当前真实可用配置是: + +```json5 +{ + "name": "ohos.permission.READ_PASTEBOARD" +} +``` + +注意: + +- `WRITE_PASTEBOARD` 不应继续写进当前 HarmonyOS 模块权限声明 +- 文档如果继续写这个权限,会直接误导后续实现 + +## 迁移检查清单(按当前实现口径) + +- [x] `ClipboardSyncConfig` 映射完成 +- [x] `ClipboardSyncService` 文本路径实现完成 +- [x] 回显抑制逻辑已接入 +- [x] `StreamingSession` 已打通 inbound callback +- [x] `ClipboardBridge` 已暴露收发接口 +- [x] `module.json5` 权限已修正为可编译形式 +- [x] 设置页已接入两个开关 +- [x] hvigor 实际构建通过 +- [ ] 真机端到端回归 +- [ ] 图片同步能力恢复 + +## 当前常见陷阱 + +### 1. 误把“图片入口保留”当成“图片功能已可用” + +现在不是这样。UI 和协议入口保留,仅表示架构已预留,不表示系统图片剪贴板已经打通。 + +### 2. 继续声明 `WRITE_PASTEBOARD` + +这会把实现重新带回不兼容状态。 + +### 3. 在 ArkTS 中大量使用未显式类型化的对象字面量 + +ArkTS 编译器对这类写法相当敏感;本轮已用显式接口/类实例方式规避。 + +### 4. 默认假设 NAPI 只会收到 `ArrayBuffer` + +实际 ArkTS 侧常会传 `Uint8Array`,bridge 必须兼容两种输入。 + +## 参考实现位置 + +### HarmonyOS + +- `entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets` +- `entry/src/main/ets/service/clipboard/ClipboardSyncService.ets` +- `entry/src/main/ets/service/jni/ClipboardBridge.ets` +- `entry/src/main/ets/service/streaming/StreamingSession.ets` +- `entry/src/main/ets/pages/StreamPage.ets` +- `entry/src/main/ets/pages/SettingsPageV2.ets` + +### Native + +- `nativelib/src/main/cpp/moonlight_bridge.cpp` +- `nativelib/src/main/cpp/callbacks.cpp` +- `nativelib/src/main/cpp/callbacks.h` +- `nativelib/src/main/cpp/napi_init.cpp` +- `nativelib/src/main/cpp/moonlight-common-c` diff --git a/docs/CLIPBOARD_SYNC_IMPLEMENTATION.md b/docs/CLIPBOARD_SYNC_IMPLEMENTATION.md new file mode 100644 index 0000000..418029b --- /dev/null +++ b/docs/CLIPBOARD_SYNC_IMPLEMENTATION.md @@ -0,0 +1,209 @@ +# 剪贴板同步实现指南 + +## 核心库定义 + +### 消息类型 + +- **消息类型:** `0x5508`(Sunshine / Gen7Enc 扩展消息) +- **消息索引:** `IDX_CLIPBOARD = 20` +- **控制包格式:** 可变长度,单包最大约 `65500` 字节(不含 ENet 头) + +### 有线帧格式(v1) + +```text +Offset Size Field +0 1 version (= 0x01) +1 1 kind (0x01 = TEXT, 0x02 = PNG) +2 4 token (u32 little-endian) +6 4 length (u32 little-endian) +10 N payload +``` + +**总头大小:** `10` 字节(`CLIPBOARD_WIRE_HEADER`) + +## 安卓实现摘要 + +安卓侧 `ClipboardSyncManager.kt` 已经具备完整实现,主要特征如下: + +- 监听系统剪贴板变化并向主机发送 +- 接收主机回显或主机主动下发的剪贴板数据 +- 使用两层回显抑制: + - `recentSentTokens`:处理主机回显 + - `pendingSelfWrites`:处理本地写剪贴板引发的监听回调 +- 文本与图片(PNG)都走同一套 wire frame 头部 + +### Android 侧错误码 + +`LiSendClipboardData()` 约定如下: + +- `0`:成功 +- `-1`:payload 无效或长度超限 +- `-2`:服务器/协议不支持 clipboard message +- `-3`:当前未连接 +- `-4`:底层 ENet 发送失败 + +## HarmonyOS 当前实现状态 + +当前仓库内的 HarmonyOS 版本已经完成**文本剪贴板同步闭环**,并通过实际构建验证。 + +### 已完成 + +- ArkTS 侧监听本地系统剪贴板文本变化 +- 本地文本变化通过 native bridge 发送到 Sunshine 主机 +- native 层接收主机下发的 clipboard 帧并回调到 ArkTS +- ArkTS 侧解析远端文本并写回本地系统剪贴板 +- 设置页提供文本/图片两个开关 +- 串流页按会话生命周期启动/停止同步服务 + +### 当前显式降级 + +- **图片同步仍保留协议入口和设置入口** +- 但由于当前 HarmonyOS 兼容 SDK 下没有在本仓验证通过的图片剪贴板 API, + `ClipboardSyncService.ets` 会在图片场景下提示: + **“当前 HarmonyOS 兼容 SDK 下未验证图片剪贴板 API,图片同步暂时禁用”** +- 该降级是故意的,目的是保证实现真实、可编译、可维护,而不是伪造一条未验证路径 + +## HarmonyOS 实现结构 + +### ArkTS 层 + +#### `ClipboardSyncConfig.ets` + +- `enableSyncText` +- `enableSyncImage` +- `echoTtlMs = 5000` +- `maxTokenHistory = 16` +- `ClipboardWireFrame.build(...)` +- `ClipboardWireFrame.parse(...)` + +#### `ClipboardSyncService.ets` + +职责: + +- 注册/注销系统剪贴板监听 +- 注册/注销 native clipboard listener +- 处理本地 → 主机发送 +- 处理主机 → 本地写回 +- 执行 host echo / self echo 抑制 + +当前已验证可编译的 HarmonyOS 文本剪贴板 API: + +- 读取:`await sysBoard.getData()` + `pasteData.getPrimaryText()` +- 写入:`pasteboard.createData(...)` + `sysBoard.setDataSync(...)` +- 监听:`sysBoard.on('update', listener)` / `sysBoard.off('update', listener)` + +#### `ClipboardBridge.ets` + +职责: + +- 保存当前 ArkTS clipboard listener +- 提供 `sendClipboardData(frame)` 给 ArkTS 调用 native +- 提供 `onClipboardDataReceived(...)` 作为 native 回调入口 + +### Streaming 集成点 + +#### `StreamingSession.ets` + +- 在 native callback 类型中加入 `clipboardData` +- 收到 native 回调后转发到 `ClipboardBridge.MoonBridge.onClipboardDataReceived(...)` + +#### `StreamPage.ets` + +- 在串流成功建立后读取设置 +- 创建 `ClipboardSyncConfig` +- 启动 `ClipboardSyncService` +- 在连接结束 / 页面消失时停止服务 + +### 设置页集成点 + +#### `SettingsService.ets` + +新增键名: + +- `ENABLE_CLIPBOARD_SYNC_TEXT` +- `ENABLE_CLIPBOARD_SYNC_IMAGE` + +#### `SettingsPageV2.ets` + +- 增加“剪贴板同步”设置分组 +- 两个开关分别对应文本/图片同步 +- 当前图片同步开关仍保留,作为未来能力恢复的 UI 入口 + +## Native 层实现要点 + +### `moonlight_bridge.cpp` + +- 新增 `GetByteArrayData(...)`,兼容 `ArrayBuffer` / `TypedArray` +- 新增 `MoonBridge_SendClipboardData(...)` +- 把 `ListenerCallbacks.clipboardData` 接入到连接回调结构 + +### `callbacks.h` / `callbacks.cpp` + +- 新增 `tsfn_clipboardData` +- 新增 `BridgeClClipboardData(...)` +- 在 native 线程收到数据后,通过 `napi_threadsafe_function` 转回 ArkTS + +### `napi_init.cpp` + +- 注册 `sendClipboardData` + +### `moonlight-common-c` 子模块 + +- 当前子模块指针前进到包含 clipboard 常量补充的提交 +- 本次 PR 需要保留该子模块更新,否则 HarmonyOS 侧 clipboard 常量和能力定义对不上 + +## 回显抑制策略 + +### Layer 1:Host Echo + +- 每次发送前生成随机 `u32 token` +- 保存到 `recentSentTokens` +- 接收来自主机的数据时,如果 token 命中历史表,则丢弃 +- 历史表带 TTL(默认 `5000ms`)和最大容量(默认 `16`) + +### Layer 2:Self Echo + +- 本地写系统剪贴板前,`pendingSelfWrites++` +- 系统 update 监听触发时,优先消费该计数并直接返回 + +## 权限与模块配置 + +HarmonyOS 当前只声明: + +- `ohos.permission.READ_PASTEBOARD` + +说明: + +- 当前兼容 SDK 下继续声明 `WRITE_PASTEBOARD` 会导致配置不成立 +- 本仓现有文本写剪贴板路径不需要额外声明该权限即可正常编译 + +## 当前限制 + +1. **仅 Sunshine 支持**:GFE 不支持 `0x5508` +2. **仅支持文本真正落地**:图片同步暂时不写入系统图片剪贴板 +3. **单帧大小受限**:约 `65500 - 10` 字节 +4. **文本编码统一 UTF-8** + +## 能力范围内已完成的闭环检查 + +- [x] ArkTS 关键文件编辑器诊断清零 +- [x] `ClipboardSyncService.ets` 尾部残留旧代码已清理 +- [x] `StreamPage.ets` 中 observer / ArkTS 对象字面量问题已修复 +- [x] native bridge 支持 `TypedArray`/`ArrayBuffer` +- [x] inbound clipboard callback 已打通到 ArkTS +- [x] `module.json5` 权限配置已修正 +- [x] DevEco hvigor 实际构建通过 + +## 暂未在本轮完成的事项 + +- [ ] 真机端到端手工验证“主机复制文本 ↔ 客户端同步文本” +- [ ] 图片剪贴板真正恢复 +- [ ] 大文本 / 高频复制 / 多语言文本等专项回归 + +## 推荐后续验证清单 + +- [ ] 本地复制短文本 → 主机能收到 +- [ ] 主机复制短文本 → 本地系统剪贴板更新 +- [ ] 禁用文本同步后不再收发文本 +- [ ] 启用图片同步时只出现一次降级提示 +- [ ] 非 Sunshine / 不支持协议的场景下行为符合预期 diff --git a/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md b/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md new file mode 100644 index 0000000..e822fe4 --- /dev/null +++ b/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md @@ -0,0 +1,191 @@ +# 鸿蒙剪贴板同步集成指南 + +> 本文档描述的是当前仓库中**已经实现并通过构建验证**的 HarmonyOS 集成方式。 +> 当前状态是:**文本剪贴板同步已闭环,图片剪贴板同步暂时降级。** + +## 目录结构 + +```text +entry/src/main/ets/ + ├── pages/ + │ ├── StreamPage.ets + │ └── SettingsPageV2.ets + ├── service/ + │ ├── SettingsService.ets + │ ├── clipboard/ + │ │ ├── ClipboardSyncConfig.ets + │ │ └── ClipboardSyncService.ets + │ ├── jni/ + │ │ └── ClipboardBridge.ets + │ └── streaming/ + │ ├── MoonBridge.ets + │ └── StreamingSession.ets +``` + +## 1. 导入模块 + +```ts +import { ClipboardSyncConfig } from '../service/clipboard/ClipboardSyncConfig' +import { ClipboardSyncObserver, ClipboardSyncService } from '../service/clipboard/ClipboardSyncService' +``` + +说明: + +- `ClipboardBridge.ets` 由服务内部和 streaming callback 使用 +- 页面层通常只需要 `ClipboardSyncConfig` 与 `ClipboardSyncService` + +## 2. 在串流页面启动服务 + +当前仓库在 `StreamPage.ets` 中处理生命周期。 + +关键流程: + +1. 串流连接建立成功后读取用户设置 +2. 若文本/图片任一开关开启,则创建 `ClipboardSyncConfig` +3. 实例化 `ClipboardSyncService` +4. 在 observer 中把错误提示转给 `ToastQueue` +5. 页面退出或连接断开时 `stop()` + +示意代码: + +```ts +const clipboardConfig = new ClipboardSyncConfig() +clipboardConfig.enableSyncText = enableClipboardSyncText +clipboardConfig.enableSyncImage = enableClipboardSyncImage + +class StreamClipboardObserver implements ClipboardSyncObserver { + onError(error: string): void { + ToastQueue.show({ message: `剪贴板同步错误: ${error}`, duration: 2000 }) + } +} + +this.clipboardSyncService = new ClipboardSyncService( + clipboardConfig, + new StreamClipboardObserver() +) +this.clipboardSyncService.start() +``` + +## 3. 设置页接入 + +当前仓库在 `SettingsPageV2.ets` 中增加“剪贴板同步”分组,并使用 `SettingsKeys` 统一读写: + +- `SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT` +- `SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE` + +建议文案含义: + +- **文本同步**:真正生效 +- **图片同步**:保留入口,但当前兼容 SDK 下会降级提示暂不支持 + +## 4. 系统剪贴板 API + +当前仓库使用 `@kit.BasicServicesKit` 中已验证可编译的文本接口: + +### 读取文本 + +```ts +const sysBoard = pasteboard.getSystemPasteboard() +const pasteData = await sysBoard.getData() +const text = pasteData.getPrimaryText() +``` + +### 写入文本 + +```ts +const sysBoard = pasteboard.getSystemPasteboard() +const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) +sysBoard.setDataSync(pasteData) +``` + +### 监听变化 + +```ts +const listener = (): void => { + void this.onPasteboardChanged() +} +sysBoard.on('update', listener) +sysBoard.off('update', listener) +``` + +## 5. 权限配置 + +### `module.json5` + +当前仅保留: + +```json5 +{ + "name": "ohos.permission.READ_PASTEBOARD", + "reason": "$string:permission_read_pasteboard_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } +} +``` + +说明: + +- 当前兼容 SDK 下不应继续声明 `WRITE_PASTEBOARD` +- 权限 `reason` 需要走资源字符串,不能直接写死不受支持格式 + +## 6. Native 层适配 + +当前 native 层已经完成以下适配: + +### 发送路径 + +- ArkTS 构建完整 wire frame +- `ClipboardBridge.MoonBridge.sendClipboardData(frame)` 调用 native +- native `MoonBridge_SendClipboardData(...)` 接受 `ArrayBuffer` / `TypedArray` +- 继续调用 `LiSendClipboardData(...)` + +### 接收路径 + +- `moonlight-common-c` 收到 `0x5508` +- `ListenerCallbacks.clipboardData` 被触发 +- `callbacks.cpp` 解析 frame 后通过 `napi_threadsafe_function` 回到 ArkTS +- `StreamingSession.ets` 把回调转发给 `ClipboardBridge.MoonBridge.onClipboardDataReceived(...)` +- `ClipboardSyncService.ets` 最终消费 `kind/token/payload` + +## 7. 图片同步当前策略 + +当前不是“没做”,而是“显式保守处理”: + +- 设置页保留图片同步入口 +- 协议仍识别 PNG kind +- 本地或远端一旦进入图片分支,只提示一次不支持 +- 不调用未验证图片剪贴板 API + +这样做的好处是: + +- 构建稳定 +- 行为明确 +- 未来恢复图片同步时,不需要重新设计协议和 UI + +## 8. 建议的人工验证步骤 + +### 基础检查 + +- [ ] 设置页能看到文本/图片同步两个开关 +- [ ] 启动串流后日志显示 clipboard sync start +- [ ] 退出串流后服务停止 + +### 文本同步 + +- [ ] 本地复制文本 → 主机端有反应 +- [ ] 主机复制文本 → 设备本地系统剪贴板更新 +- [ ] 关闭文本同步后不再收发文本 + +### 图片同步降级 + +- [ ] 打开图片同步后,仅出现一次降级提示 +- [ ] 收到主机 PNG 时不会崩溃、不会写入错误数据 + +## 9. 当前已知限制 + +1. 仅 Sunshine 支持该协议扩展 +2. 兼容 SDK 下图片剪贴板 API 仍待后续验证 +3. 大 payload 仍受单帧大小限制 +4. 本轮验证以编译通过和代码闭环为主,不含真机端到端专项回归 diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index cb0282b..793735b 100644 --- a/entry/src/main/ets/pages/SettingsPageV2.ets +++ b/entry/src/main/ets/pages/SettingsPageV2.ets @@ -165,6 +165,10 @@ struct SettingsPageV2 { @State micBitrate: number = 64; @State audioCompatMode: boolean = false; + // 剪贴板同步(Sunshine only) + @State enableClipboardSyncText: boolean = false; + @State enableClipboardSyncImage: boolean = false; + // 输入 - 手柄设置 @State enableVibration: boolean = true; @State vibrationMode: string = '自动'; // 自动/仅手柄/仅设备/同时 @@ -268,6 +272,7 @@ struct SettingsPageV2 { private sidebarGroups: SidebarGroupMeta[] = [ { id: 'video', icon: $r('sys.symbol.video_fill'), title: '视频' }, { id: 'audio', icon: $r('sys.symbol.speaker_wave_2_fill'), title: '音频' }, + { id: 'clipboard', icon: $r('sys.symbol.share'), title: '剪贴板同步' }, { id: 'gamepad', icon: $r('sys.symbol.gamecontroller_fill'), title: '手柄' }, { id: 'usb_driver', icon: $r('sys.symbol.charg_cable'), title: 'USB 驱动' }, { id: 'osc', icon: $r('sys.symbol.gamecontroller'), title: '屏幕控制器' }, @@ -493,6 +498,10 @@ struct SettingsPageV2 { this.micBitrate = await PreferencesUtil.get(SettingsKeys.MIC_BITRATE, 64); this.audioCompatMode = await this.loadBoolean(SettingsKeys.AUDIO_COMPAT_MODE, false); + // 剪贴板同步设置 + this.enableClipboardSyncText = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); + this.enableClipboardSyncImage = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + // 输入 - 手柄设置 this.enableVibration = await this.loadBoolean(SettingsKeys.ENABLE_VIBRATION, true); this.vibrationMode = await PreferencesUtil.get(SettingsKeys.VIBRATION_MODE, '自动'); @@ -1304,6 +1313,35 @@ struct SettingsPageV2 { ] }) + // 剪贴板同步设置(仅 Sunshine) + this.SettingsGroup({ + id: 'clipboard', + icon: $r('sys.symbol.share'), + title: '剪贴板同步', + items: [ + { + title: '同步文本', + subtitle: 'Sunshine only - 与主机同步剪贴板文本', + type: 'toggle', + value: this.enableClipboardSyncText, + action: () => { + this.enableClipboardSyncText = !this.enableClipboardSyncText; + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, this.enableClipboardSyncText); + } + }, + { + title: '同步图片', + subtitle: 'Sunshine only - 同步 PNG 格式的剪贴板图片', + type: 'toggle', + value: this.enableClipboardSyncImage, + action: () => { + this.enableClipboardSyncImage = !this.enableClipboardSyncImage; + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, this.enableClipboardSyncImage); + } + } + ] + }) + // 手柄设置 this.SettingsGroup({ id: 'gamepad', diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 67e7a07..f346461 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -50,6 +50,8 @@ import { AiKeyService, AiKeyRecommendation } from '../service/AiKeyService'; import { ComputerManager } from '../service/ComputerManager'; import { ToastQueue } from '../utils/ToastQueue'; import { NetworkBoostService, SystemNetScene, SystemNetSceneListener } from '../service/network/NetworkBoostService'; +import { ClipboardSyncObserver, ClipboardSyncService } from '../service/clipboard/ClipboardSyncService'; +import { ClipboardSyncConfig } from '../service/clipboard/ClipboardSyncConfig'; /** MouseEmulationCallback 的具体实现,桥接 MoonBridge 鼠标 API */ class MoonBridgeMouseCallback implements MouseEmulationCallback { @@ -70,6 +72,12 @@ class MoonBridgeMouseCallback implements MouseEmulationCallback { } } +class StreamClipboardObserver implements ClipboardSyncObserver { + onError(error: string): void { + ToastQueue.show({ message: `剪贴板同步错误: ${error}`, duration: 2000 }); + } +} + interface StreamPageParams { computerId: string; appId: number; @@ -188,6 +196,7 @@ struct StreamPage { private inputHandler: StreamInputHandler = new StreamInputHandler(); private lifecycleManager: StreamLifecycleManager = new StreamLifecycleManager(); private imeManager: StreamIMEManager = new StreamIMEManager(getContext(this) as common.UIAbilityContext); + private clipboardSyncService: ClipboardSyncService | null = null; // 对话框控制器 private gameMenuDialogController: CustomDialogController | null = null; @@ -664,6 +673,12 @@ struct StreamPage { aboutToDisappear(): void { console.info('StreamPage aboutToDisappear - cleaning up'); + // 停止剪贴板同步 + if (this.clipboardSyncService) { + this.clipboardSyncService.stop(); + this.clipboardSyncService = null; + } + // 取消 Network Boost 场景监听 if (this.netSceneListener) { NetworkBoostService.getInstance().removeSceneListener(this.netSceneListener); @@ -1286,6 +1301,11 @@ struct StreamPage { // 设置连接终止回调 this.viewModel.setConnectionTerminatedCallback((errorCode: number) => { + // 停止剪贴板同步 + if (this.clipboardSyncService) { + this.clipboardSyncService.stop(); + this.clipboardSyncService = null; + } this.lifecycleManager.handleConnectionTerminated(errorCode); }); @@ -1298,6 +1318,25 @@ struct StreamPage { await this.launchStream(); this.streamStartedAtMs = Date.now(); + // 启动剪贴板同步 + try { + const enableClipboardSyncText = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); + const enableClipboardSyncImage = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + if (enableClipboardSyncText || enableClipboardSyncImage) { + const clipboardConfig = new ClipboardSyncConfig(); + clipboardConfig.enableSyncText = enableClipboardSyncText; + clipboardConfig.enableSyncImage = enableClipboardSyncImage; + const clipboardObserver: ClipboardSyncObserver = new StreamClipboardObserver(); + this.clipboardSyncService = new ClipboardSyncService( + clipboardConfig, + clipboardObserver + ); + this.clipboardSyncService.start(); + } + } catch (error) { + console.error('Failed to initialize clipboard sync:', error); + } + // 超分辨率状态 Toast this.showUpscaleToast(); diff --git a/entry/src/main/ets/service/SettingsService.ets b/entry/src/main/ets/service/SettingsService.ets index 69b7f54..452c875 100644 --- a/entry/src/main/ets/service/SettingsService.ets +++ b/entry/src/main/ets/service/SettingsService.ets @@ -60,6 +60,10 @@ export class SettingsKeys { static readonly MIC_BITRATE: string = 'settings_mic_bitrate'; static readonly AUDIO_COMPAT_MODE: string = 'settings_audio_compat_mode'; + // 剪贴板同步设置 + static readonly ENABLE_CLIPBOARD_SYNC_TEXT: string = 'enableClipboardSyncText'; + static readonly ENABLE_CLIPBOARD_SYNC_IMAGE: string = 'enableClipboardSyncImage'; + // 输入 - 手柄设置 static readonly ENABLE_VIBRATION: string = 'settings_enable_vibration'; static readonly VIBRATION_MODE: string = 'settings_vibration_mode'; // 震动模式:自动/仅手柄/仅设备/同时 diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets new file mode 100644 index 0000000..222d536 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets @@ -0,0 +1,161 @@ +/** + * Clipboard sync configuration and data structures. + * Based on Sunshine clipboard sync protocol (moonlight-common-c PR #5, control packet 0x5508) + */ + +/** + * Configuration for clipboard synchronization. + */ +export class ClipboardSyncConfig { + /** Sync changes to local clipboard text with the host */ + enableSyncText: boolean = false + + /** Sync local clipboard images (PNG) with the host */ + enableSyncImage: boolean = false + + /** TTL for echo suppression token history (ms) */ + echoTtlMs: number = 5000 + + /** Maximum number of recent sent tokens to track */ + maxTokenHistory: number = 16 + + constructor(partial?: Partial) { + if (!partial) { + return + } + + if (partial.enableSyncText !== undefined) { + this.enableSyncText = partial.enableSyncText + } + if (partial.enableSyncImage !== undefined) { + this.enableSyncImage = partial.enableSyncImage + } + if (partial.echoTtlMs !== undefined) { + this.echoTtlMs = partial.echoTtlMs + } + if (partial.maxTokenHistory !== undefined) { + this.maxTokenHistory = partial.maxTokenHistory + } + } +} + +/** + * Entry in the recent sent tokens queue for echo suppression. + */ +export class TokenEntry { + token: number + timestamp: number + + constructor(token: number, timestamp: number) { + this.token = token + this.timestamp = timestamp + } +} + +/** + * Wire format for clipboard sync (v1). + * Layout (little-endian): + * - Offset 0: u8 version (= 0x01) + * - Offset 1: u8 kind (0x01 = TEXT, 0x02 = PNG) + * - Offset 2-5: u32 token (32-bit random for echo suppression) + * - Offset 6-9: u32 length (payload size) + * - Offset 10+: payload (UTF-8 text or PNG bytes) + */ +export interface ClipboardWireParseResult { + kind: number + token: number + payload: Uint8Array +} + +export class ClipboardWireFrame { + static readonly WIRE_VERSION = 1 + static readonly HEADER_SIZE = 10 // version(1) + kind(1) + token(4) + length(4) + + static readonly KIND_TEXT = 1 + static readonly KIND_PNG = 2 + + /** + * Build a wire frame for sending clipboard data. + * @param kind Clipboard data type (KIND_TEXT or KIND_PNG) + * @param token Echo suppression token + * @param payload Data to send + * @returns Wire frame bytes + */ + static build(kind: number, token: number, payload: Uint8Array): Uint8Array { + const totalLen = ClipboardWireFrame.HEADER_SIZE + payload.length + const frame = new Uint8Array(totalLen) + const view = new DataView(frame.buffer, frame.byteOffset, totalLen) + + // Version + view.setUint8(0, ClipboardWireFrame.WIRE_VERSION) + + // Kind + view.setUint8(1, kind) + + // Token (little-endian u32) + view.setUint32(2, token, true) + + // Length (little-endian u32) + view.setUint32(6, payload.length, true) + + // Payload + frame.set(payload, ClipboardWireFrame.HEADER_SIZE) + + return frame + } + + /** + * Parse a wire frame received from the host. + * @param frame Wire frame bytes + * @returns { kind, token, payload } or null if invalid + */ + static parse(frame: Uint8Array): ClipboardWireParseResult | null { + if (frame.length < ClipboardWireFrame.HEADER_SIZE) { + return null + } + + const view = new DataView(frame.buffer, frame.byteOffset, frame.length) + + // Check version + const version = view.getUint8(0) + if (version !== ClipboardWireFrame.WIRE_VERSION) { + return null + } + + const kind = view.getUint8(1) + const token = view.getUint32(2, true) + const length = view.getUint32(6, true) + + if (ClipboardWireFrame.HEADER_SIZE + length !== frame.length) { + return null + } + + const payload = frame.slice(ClipboardWireFrame.HEADER_SIZE, ClipboardWireFrame.HEADER_SIZE + length) + + const result: ClipboardWireParseResult = { + kind: kind, + token: token, + payload: payload + } + return result + } +} + +/** + * Callback interface for clipboard sync events. + */ +export interface ClipboardSyncListener { + /** + * Called when clipboard data is received from the host. + * @param kind Data type (KIND_TEXT or KIND_PNG) + * @param token Echo suppression token + * @param payload Data bytes + */ + onClipboardData(kind: number, token: number, payload: Uint8Array): void + + /** + * Called when clipboard sync encounters an error. + * @param error Error message + */ + onError?(error: string): void +} diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets new file mode 100644 index 0000000..cd0eb7f --- /dev/null +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -0,0 +1,262 @@ +import { pasteboard, BusinessError } from '@kit.BasicServicesKit' +import { StringUtil } from '../../utils/StringUtil' +import { MoonBridge } from '../jni/ClipboardBridge' +import { + ClipboardSyncConfig, + ClipboardWireFrame, + TokenEntry, + ClipboardSyncListener +} from './ClipboardSyncConfig' + +const TAG = 'ClipboardSyncService' +const MAX_CLIPBOARD_FRAME_BYTES = 65500 - ClipboardWireFrame.HEADER_SIZE + +type PasteboardUpdateListener = () => void + +export interface ClipboardSyncObserver { + onError?(error: string): void +} + +function logInfo(message: string): void { + console.info(`[${TAG}] ${message}`) +} + +function logWarn(message: string): void { + console.warn(`[${TAG}] ${message}`) +} + +function logError(message: string): void { + console.error(`[${TAG}] ${message}`) +} + +function formatError(err: Object): string { + const businessError = err as BusinessError + const error = err as Error + if (businessError && businessError.message) { + return businessError.message + } + if (error && error.message) { + return error.message + } + return 'unknown error' +} + +/** + * Manages clipboard synchronization between HarmonyOS and Sunshine host. + * + * 当前 SDK 已验证文本剪贴板 API;图片剪贴板相关接口在本项目兼容 SDK 下不可用, + * 因此图片同步暂时显式降级为 no-op,并保留协议层入口以便未来恢复。 + */ +export class ClipboardSyncService implements ClipboardSyncListener { + private config: ClipboardSyncConfig + private observer: ClipboardSyncObserver | null = null + private recentSentTokens: TokenEntry[] = [] + private pendingSelfWrites: number = 0 + private pasteboardEventListener: PasteboardUpdateListener | null = null + private isRunning: boolean = false + private imageSupportWarned: boolean = false + + constructor(config?: ClipboardSyncConfig, observer?: ClipboardSyncObserver) { + this.config = config ? config : new ClipboardSyncConfig() + this.observer = observer ? observer : null + } + + start(): void { + if (this.isRunning) { + return + } + + if (!this.config.enableSyncText && !this.config.enableSyncImage) { + logInfo('Clipboard sync disabled in config') + return + } + + if (this.config.enableSyncImage) { + this.warnImageSyncUnsupported('start') + } + + this.setupPasteboardListener() + this.registerWithMoonBridge() + this.isRunning = true + + logInfo(`Clipboard sync started (text=${this.config.enableSyncText}, image=${this.config.enableSyncImage})`) + } + + stop(): void { + if (!this.isRunning) { + return + } + + this.unregisterFromMoonBridge() + this.removePasteboardListener() + this.recentSentTokens = [] + this.pendingSelfWrites = 0 + this.isRunning = false + + logInfo('Clipboard sync stopped') + } + + private setupPasteboardListener(): void { + try { + const sysBoard = pasteboard.getSystemPasteboard() + const listener: PasteboardUpdateListener = (): void => { + void this.onPasteboardChanged() + } + this.pasteboardEventListener = listener + sysBoard.on('update', listener) + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to setup pasteboard listener: ${message}`) + this.observer?.onError?.(`无法监听剪贴板:${message}`) + } + } + + private removePasteboardListener(): void { + if (!this.pasteboardEventListener) { + return + } + + try { + const sysBoard = pasteboard.getSystemPasteboard() + sysBoard.off('update', this.pasteboardEventListener) + this.pasteboardEventListener = null + } catch (err) { + logError(`Failed to remove pasteboard listener: ${formatError(err as Object)}`) + } + } + + private registerWithMoonBridge(): void { + MoonBridge.setClipboardListener(this) + } + + private unregisterFromMoonBridge(): void { + MoonBridge.setClipboardListener(null) + } + + private async onPasteboardChanged(): Promise { + if (this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + return + } + + if (!this.config.enableSyncText) { + return + } + + try { + const sysBoard = pasteboard.getSystemPasteboard() + const pasteData = await sysBoard.getData() + const text = pasteData.getPrimaryText() + if (!text || text.length === 0) { + return + } + + this.sendPayload( + ClipboardWireFrame.KIND_TEXT, + this.generateToken(), + StringUtil.stringToUint8Array(text) + ) + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to read local clipboard: ${message}`) + this.observer?.onError?.(`读取本地剪贴板失败:${message}`) + } + } + + private sendPayload(kind: number, token: number, payload: Uint8Array): void { + if (payload.length > MAX_CLIPBOARD_FRAME_BYTES) { + logWarn(`Clipboard payload kind=${kind} size=${payload.length} exceeds limit, dropping`) + return + } + + this.recordSentToken(token) + const frame = ClipboardWireFrame.build(kind, token, payload) + + try { + const result = MoonBridge.sendClipboardData(frame) + if (result !== 0) { + logWarn(`sendClipboardData kind=${kind} size=${payload.length} rc=${result}`) + } + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to send clipboard data: ${message}`) + this.observer?.onError?.(`发送剪贴板失败:${message}`) + } + } + + onClipboardData(kind: number, token: number, payload: Uint8Array): void { + void this.applyRemoteClipboardData(kind, token, payload) + } + + private async applyRemoteClipboardData(kind: number, token: number, payload: Uint8Array): Promise { + if (this.isHostEcho(token)) { + return + } + + if (kind === ClipboardWireFrame.KIND_PNG) { + if (this.config.enableSyncImage) { + this.warnImageSyncUnsupported('remote') + } + return + } + + if (kind !== ClipboardWireFrame.KIND_TEXT || !this.config.enableSyncText) { + return + } + + try { + const text = StringUtil.uint8ArrayToString(payload) + if (text.length === 0) { + return + } + + this.pendingSelfWrites++ + const sysBoard = pasteboard.getSystemPasteboard() + const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) + sysBoard.setDataSync(pasteData) + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to set local clipboard: ${message}`) + this.observer?.onError?.(`写入本地剪贴板失败:${message}`) + } + } + + private isHostEcho(token: number): boolean { + const now = Date.now() + this.recentSentTokens = this.recentSentTokens.filter((entry: TokenEntry): boolean => { + return now - entry.timestamp <= this.config.echoTtlMs + }) + + const index = this.recentSentTokens.findIndex((entry: TokenEntry): boolean => { + return entry.token === token + }) + if (index >= 0) { + this.recentSentTokens.splice(index, 1) + return true + } + + return false + } + + private recordSentToken(token: number): void { + this.recentSentTokens.push(new TokenEntry(token, Date.now())) + if (this.recentSentTokens.length > this.config.maxTokenHistory) { + this.recentSentTokens.shift() + } + } + + private generateToken(): number { + return Math.floor(Math.random() * 0x100000000) >>> 0 + } + + private warnImageSyncUnsupported(source: string): void { + if (this.imageSupportWarned) { + return + } + + this.imageSupportWarned = true + const message = '当前 HarmonyOS 兼容 SDK 下未验证图片剪贴板 API,图片同步暂时禁用' + logWarn(`${message} (${source})`) + this.observer?.onError?.(message) + } +} diff --git a/entry/src/main/ets/service/jni/ClipboardBridge.ets b/entry/src/main/ets/service/jni/ClipboardBridge.ets new file mode 100644 index 0000000..39eab9d --- /dev/null +++ b/entry/src/main/ets/service/jni/ClipboardBridge.ets @@ -0,0 +1,127 @@ +/** + * JNI bridge to native moonlight-common-c for clipboard synchronization. + * + * This module provides native bindings for sending and receiving clipboard data + * through the Sunshine control stream (message type 0x5508). + */ + +import { ClipboardSyncListener } from '../clipboard/ClipboardSyncConfig' + +/** + * Clipboard-related native bindings and constants. + */ +export namespace MoonBridge { + /** + * Clipboard data kinds (from ClipboardSyncManager.kt). + */ + export enum ClipboardKind { + TEXT = 1, + PNG = 2 + } + + /** + * Wire frame header size in bytes (version + kind + token + length). + */ + export const CLIPBOARD_WIRE_HEADER = 10 + + /** + * Maximum clipboard payload size (one ENet control packet - header). + */ + export const MAX_CLIPBOARD_PAYLOAD = 65500 - CLIPBOARD_WIRE_HEADER + + // Error codes from LiSendClipboardData + export enum SendErrorCode { + SUCCESS = 0, + INVALID_PAYLOAD = -1, // payload null, <= 0 bytes, or > 65535 bytes + NOT_SUPPORTED = -2, // Not Sunshine + Gen5+, or server doesn't support clipboard + DISCONNECTED = -3, // Peer not connected + SEND_FAILED = -4 // ENet send failed + } + + /** + * Global clipboard listener (singleton). + */ + let clipboardListener: ClipboardSyncListener | null = null + + /** + * Register a listener for inbound clipboard data from host. + */ + export function setClipboardListener(listener: ClipboardSyncListener | null): void { + clipboardListener = listener + } + + /** + * Send clipboard data to the host. + * + * Builds a v1 wire frame and sends it via the control stream. + * + * Wire format (little-endian): + * - Offset 0: u8 version (= 0x01) + * - Offset 1: u8 kind + * - Offset 2-5: u32 token + * - Offset 6-9: u32 length + * - Offset 10+: payload + * + * @param frame Complete wire frame including header and payload + * @returns 0 on success, negative error code on failure + */ + export function sendClipboardData(frame: Uint8Array): number { + if (frame.length < CLIPBOARD_WIRE_HEADER) { + return SendErrorCode.INVALID_PAYLOAD + } + + if (frame.length > 65535) { + return SendErrorCode.INVALID_PAYLOAD + } + + // Call native method + return nativeSendClipboardData(frame) + } + + /** + * Called from native code when clipboard data arrives from the host. + * The caller must ensure the payload is properly framed. + * + * @param kind Clipboard data type (KIND_TEXT or KIND_PNG) + * @param token Echo suppression token + * @param payload Data bytes + * @internal + */ + export function onClipboardDataReceived(kind: number, token: number, payload: Uint8Array): void { + if (clipboardListener) { + try { + clipboardListener.onClipboardData(kind, token, payload) + } catch (error) { + console.error(`ClipboardSyncListener.onClipboardData threw: ${error}`) + } + } + } + + /** + * Native binding: Send clipboard frame data. + * + * This is called by sendClipboardData after building the complete wire frame. + * The native implementation will: + * 1. Validate the frame format + * 2. Check server version and capabilities + * 3. Send via control stream (0x5508 message) + * + * @param frame Complete wire frame (version + kind + token + length + payload) + * @returns SendErrorCode result + * @nativeMethod moonlight_jni.cpp: Java_com_limelight_nvstream_jni_MoonBridge_nativeSendClipboardData + */ + declare function nativeSendClipboardData(frame: Uint8Array): number +} + +/** + * Native clipboard callback interface. + * + * When the control stream thread receives a 0x5508 clipboard packet, + * it calls this Java interface method to route the data to the active listener. + */ +export interface IClipboardCallback { + /** + * Invoked by native code when clipboard data arrives from the host. + */ + onClipboardData(kind: number, token: number, payload: Uint8Array): void +} diff --git a/entry/src/main/ets/service/streaming/MoonBridge.ets b/entry/src/main/ets/service/streaming/MoonBridge.ets index 98b1a43..eabea9a 100644 --- a/entry/src/main/ets/service/streaming/MoonBridge.ets +++ b/entry/src/main/ets/service/streaming/MoonBridge.ets @@ -237,6 +237,8 @@ export interface ConnectionListenerCallbacks { connectionStatusUpdate?: (connectionStatus: number) => void; /** 分辨率变更 */ resolutionChanged?: (width: number, height: number) => void; + /** 剪贴板同步数据 */ + clipboardData?: (kind: number, token: number, payload: Uint8Array) => void; } export interface MoonBridgeCallbacks extends VideoDecoderCallbacks, AudioRendererCallbacks, ConnectionListenerCallbacks { diff --git a/entry/src/main/ets/service/streaming/StreamingSession.ets b/entry/src/main/ets/service/streaming/StreamingSession.ets index b4e9a7c..aa4cc67 100644 --- a/entry/src/main/ets/service/streaming/StreamingSession.ets +++ b/entry/src/main/ets/service/streaming/StreamingSession.ets @@ -23,6 +23,7 @@ import { MicrophoneStream } from '../microphone/MicrophoneStream'; import { AudioVibrationService } from '../AudioVibrationService'; import { AdaptiveBitrateService } from './AdaptiveBitrateService'; import { NetworkBoostService } from '../network/NetworkBoostService'; +import { MoonBridge as ClipboardNativeBridge } from '../jni/ClipboardBridge'; import { MoonBridge, CONTROLLER_TYPE_UNKNOWN, @@ -180,6 +181,7 @@ interface StreamCallbacks { setMotionEventState?: (controllerNumber: number, motionType: number, reportRateHz: number) => void; setControllerLED?: (controllerNumber: number, r: number, g: number, b: number) => void; resolutionChanged?: (width: number, height: number) => void; + clipboardData?: (kind: number, token: number, payload: Uint8Array) => void; bassEnergy?: (intensity: number, lowFreqRatio: number, stereoBalance: number) => void; } @@ -1024,6 +1026,9 @@ export class StreamingSession { this.connectionStatusCallback(status); } }, + clipboardData: (kind: number, token: number, payload: Uint8Array): void => { + ClipboardNativeBridge.onClipboardDataReceived(kind, token, payload); + }, setHdrMode: (enabled: boolean): void => { console.info(`HDR 模式变化通知: ${enabled}`); if (this.config && this.config.hdr !== enabled) { diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 9ede15a..35aa905 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -116,6 +116,14 @@ "when": "inuse" } }, + { + "name": "ohos.permission.READ_PASTEBOARD", + "reason": "$string:permission_read_pasteboard_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } + }, { "name": "ohos.permission.FILE_ACCESS_PERSIST", "reason": "$string:permission_file_persist_reason", diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index ef397ea..fcfea8d 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -124,6 +124,10 @@ "name": "permission_usb_ddk_reason", "value": "用于通过原生接口直接访问USB手柄设备,实现高速轮询" }, + { + "name": "permission_read_pasteboard_reason", + "value": "用于读取本地剪贴板并与 Sunshine 主机同步" + }, { "name": "permission_file_persist_reason", "value": "用于自动备份设置到用户选择的文件,免去每次手动导出" diff --git a/nativelib/src/main/cpp/callbacks.cpp b/nativelib/src/main/cpp/callbacks.cpp index 8c4c5d2..0031dfc 100644 --- a/nativelib/src/main/cpp/callbacks.cpp +++ b/nativelib/src/main/cpp/callbacks.cpp @@ -27,6 +27,7 @@ // 外部函数:更新 mic 编码器丢包率(定义在 moonlight_bridge.cpp) extern void MicCapturerUpdatePacketLossPercent(int percent); #include +#include #include #include #include @@ -265,6 +266,32 @@ static void CallJs_ResolutionChanged(napi_env env, napi_value js_callback, void* delete cbData; } +static void CallJs_ClipboardData(napi_env env, napi_value js_callback, void* context, void* data) { + CallbackData* cbData = (CallbackData*)data; + if (env != nullptr && js_callback != nullptr) { + napi_value argv[3]; + napi_create_int32(env, cbData->intParams[0], &argv[0]); + napi_create_double(env, cbData->doubleParams[0], &argv[1]); + + void* bufferData = nullptr; + napi_value arrayBuffer; + napi_create_arraybuffer(env, cbData->ptrSize, &bufferData, &arrayBuffer); + if (cbData->ptrSize > 0 && cbData->ptrParam != nullptr) { + memcpy(bufferData, cbData->ptrParam, cbData->ptrSize); + } + napi_create_typedarray(env, napi_uint8_array, cbData->ptrSize, arrayBuffer, 0, &argv[2]); + + napi_value undefined; + napi_get_undefined(env, &undefined); + napi_call_function(env, undefined, js_callback, 3, argv, nullptr); + } + + if (cbData != nullptr && cbData->ptrParam != nullptr) { + free(cbData->ptrParam); + } + delete cbData; +} + static void CallJs_DrSetup(napi_env env, napi_value js_callback, void* context, void* data) { CallbackData* cbData = (CallbackData*)data; if (env != nullptr && js_callback != nullptr) { @@ -447,6 +474,9 @@ void Callbacks_Init(napi_env env, napi_value callbacks) { if (napi_get_named_property(env, callbacks, "resolutionChanged", &callback) == napi_ok) { CreateThreadsafeFunction(env, callback, "resolutionChanged", CallJs_ResolutionChanged, &g_connCallbacks.tsfn_resolutionChanged); } + if (napi_get_named_property(env, callbacks, "clipboardData", &callback) == napi_ok) { + CreateThreadsafeFunction(env, callback, "clipboardData", CallJs_ClipboardData, &g_connCallbacks.tsfn_clipboardData); + } OH_LOG_INFO(LOG_APP, "Callbacks initialized"); } @@ -480,6 +510,7 @@ void Callbacks_Cleanup(void) { if (g_connCallbacks.tsfn_setControllerLED) napi_release_threadsafe_function(g_connCallbacks.tsfn_setControllerLED, napi_tsfn_release); if (g_connCallbacks.tsfn_rumbleTriggers) napi_release_threadsafe_function(g_connCallbacks.tsfn_rumbleTriggers, napi_tsfn_release); if (g_connCallbacks.tsfn_resolutionChanged) napi_release_threadsafe_function(g_connCallbacks.tsfn_resolutionChanged, napi_tsfn_release); + if (g_connCallbacks.tsfn_clipboardData) napi_release_threadsafe_function(g_connCallbacks.tsfn_clipboardData, napi_tsfn_release); memset(&g_videoCallbacks, 0, sizeof(g_videoCallbacks)); memset(&g_audioCallbacks, 0, sizeof(g_audioCallbacks)); @@ -971,6 +1002,62 @@ void BridgeClResolutionChanged(unsigned int width, unsigned int height) { } } +void BridgeClClipboardData(const char* data, int length) { + if (g_connCallbacks.tsfn_clipboardData == nullptr || data == nullptr || length < 10) { + return; + } + + const uint8_t* frame = reinterpret_cast(data); + const uint8_t version = frame[0]; + const uint8_t kind = frame[1]; + const uint32_t token = + static_cast(frame[2]) | + (static_cast(frame[3]) << 8) | + (static_cast(frame[4]) << 16) | + (static_cast(frame[5]) << 24); + const uint32_t payloadLength = + static_cast(frame[6]) | + (static_cast(frame[7]) << 8) | + (static_cast(frame[8]) << 16) | + (static_cast(frame[9]) << 24); + const int availablePayloadLength = length - 10; + + if (version != 1) { + OH_LOG_WARN(LOG_APP, "BridgeClClipboardData: unsupported version=%{public}u", version); + return; + } + + if (payloadLength != static_cast(availablePayloadLength)) { + OH_LOG_WARN(LOG_APP, "BridgeClClipboardData: malformed payload len=%{public}u available=%{public}d", + payloadLength, availablePayloadLength); + return; + } + + CallbackData* cbData = new CallbackData(); + memset(cbData, 0, sizeof(*cbData)); + cbData->intParams[0] = static_cast(kind); + cbData->doubleParams[0] = static_cast(token); + cbData->ptrSize = availablePayloadLength; + + if (availablePayloadLength > 0) { + cbData->ptrParam = malloc(availablePayloadLength); + if (cbData->ptrParam == nullptr) { + delete cbData; + OH_LOG_ERROR(LOG_APP, "BridgeClClipboardData: malloc failed for %{public}d bytes", availablePayloadLength); + return; + } + memcpy(cbData->ptrParam, frame + 10, availablePayloadLength); + } + + napi_status st = napi_call_threadsafe_function(g_connCallbacks.tsfn_clipboardData, cbData, napi_tsfn_blocking); + if (st != napi_ok) { + if (cbData->ptrParam != nullptr) { + free(cbData->ptrParam); + } + delete cbData; + } +} + void BridgeClLogMessage(const char* format, ...) { va_list args; va_start(args, format); diff --git a/nativelib/src/main/cpp/callbacks.h b/nativelib/src/main/cpp/callbacks.h index dcab239..96ec6cd 100644 --- a/nativelib/src/main/cpp/callbacks.h +++ b/nativelib/src/main/cpp/callbacks.h @@ -87,6 +87,7 @@ typedef struct { napi_threadsafe_function tsfn_setMotionEventState; napi_threadsafe_function tsfn_setControllerLED; napi_threadsafe_function tsfn_resolutionChanged; + napi_threadsafe_function tsfn_clipboardData; } ConnectionListenerCallbacks; // ============================================================================= @@ -128,6 +129,7 @@ void BridgeClRumbleTriggers(unsigned short controllerNumber, unsigned short left void BridgeClSetMotionEventState(unsigned short controllerNumber, unsigned char motionType, unsigned short reportRateHz); void BridgeClSetControllerLED(unsigned short controllerNumber, unsigned char r, unsigned char g, unsigned char b); void BridgeClResolutionChanged(unsigned int width, unsigned int height); +void BridgeClClipboardData(const char* data, int length); void BridgeClLogMessage(const char* format, ...); #ifdef __cplusplus diff --git a/nativelib/src/main/cpp/moonlight-common-c b/nativelib/src/main/cpp/moonlight-common-c index 80f2408..3dde0f1 160000 --- a/nativelib/src/main/cpp/moonlight-common-c +++ b/nativelib/src/main/cpp/moonlight-common-c @@ -1 +1 @@ -Subproject commit 80f240852d7bed78161cd2862226119b91a3f7bb +Subproject commit 3dde0f103aeaa5c7396a676159887545b77414e5 diff --git a/nativelib/src/main/cpp/moonlight_bridge.cpp b/nativelib/src/main/cpp/moonlight_bridge.cpp index 749055c..facbf06 100644 --- a/nativelib/src/main/cpp/moonlight_bridge.cpp +++ b/nativelib/src/main/cpp/moonlight_bridge.cpp @@ -23,6 +23,9 @@ extern "C" { // 从 MicrophoneStream.c 导出的函数 int sendMicrophoneOpusData(const unsigned char* data, int length); bool isMicrophoneEncryptionEnabled(void); + +// 从 ControlStream.c 导出的函数(剪贴板同步) +int LiSendClipboardData(const void* payload, int length); } #include "moonlight_bridge.h" @@ -103,7 +106,9 @@ static CONNECTION_LISTENER_CALLBACKS g_connCallbacksStruct = { .rumbleTriggers = BridgeClRumbleTriggers, .setMotionEventState = BridgeClSetMotionEventState, .setControllerLED = BridgeClSetControllerLED, + .setAdaptiveTriggers = nullptr, .resolutionChanged = (void (*)(uint32_t, uint32_t))BridgeClResolutionChanged, + .clipboardData = BridgeClClipboardData, }; // ============================================================================= @@ -163,6 +168,27 @@ static bool GetBool(napi_env env, napi_value value, bool* result) { return true; } +static bool GetByteArrayData(napi_env env, napi_value value, void** data, size_t* length) { + bool isTypedArray = false; + napi_status status = napi_is_typedarray(env, value, &isTypedArray); + if (status == napi_ok && isTypedArray) { + napi_typedarray_type type; + napi_value arrayBuffer; + size_t byteOffset = 0; + return napi_get_typedarray_info(env, value, &type, length, data, &arrayBuffer, &byteOffset) == napi_ok; + } + + bool isArrayBuffer = false; + status = napi_is_arraybuffer(env, value, &isArrayBuffer); + if (status == napi_ok && isArrayBuffer) { + return napi_get_arraybuffer_info(env, value, data, length) == napi_ok; + } + + *data = nullptr; + *length = 0; + return false; +} + // ============================================================================= // 模块初始化 // ============================================================================= @@ -794,7 +820,7 @@ napi_value MoonBridge_SendMicrophoneOpusData(napi_env env, napi_callback_info in void* data = nullptr; size_t length = 0; - napi_get_arraybuffer_info(env, args[0], &data, &length); + GetByteArrayData(env, args[0], &data, &length); int ret = -1; if (data && length > 0) { @@ -812,6 +838,34 @@ napi_value MoonBridge_IsMicrophoneEncryptionEnabled(napi_env env, napi_callback_ return result; } +// ============================================================================= +// 剪贴板同步(Sunshine protocol extension) +// ============================================================================= + +/** + * 发送剪贴板数据到主机 + * 参数:Uint8Array(包含完整的有线帧:version + kind + token + length + payload) + * 返回:错误码 (0 = 成功, < 0 = 错误) + */ +napi_value MoonBridge_SendClipboardData(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + void* data = nullptr; + size_t length = 0; + GetByteArrayData(env, args[0], &data, &length); + + int ret = -1; + if (data && length >= 10 && length <= 65535) { // 最少 10 字节头,最多 65535 字节 + ret = LiSendClipboardData(data, (int)length); + } + + napi_value result; + napi_create_int32(env, ret, &result); + return result; +} + // ============================================================================= // Opus 编码器 // ============================================================================= diff --git a/nativelib/src/main/cpp/moonlight_bridge.h b/nativelib/src/main/cpp/moonlight_bridge.h index 8561b54..5e2bf9e 100644 --- a/nativelib/src/main/cpp/moonlight_bridge.h +++ b/nativelib/src/main/cpp/moonlight_bridge.h @@ -110,6 +110,12 @@ napi_value MoonBridge_IsMicrophoneRequested(napi_env env, napi_callback_info inf napi_value MoonBridge_SendMicrophoneOpusData(napi_env env, napi_callback_info info); napi_value MoonBridge_IsMicrophoneEncryptionEnabled(napi_env env, napi_callback_info info); +// ============================================================================= +// 剪贴板同步(Sunshine protocol extension) +// ============================================================================= + +napi_value MoonBridge_SendClipboardData(napi_env env, napi_callback_info info); + // ============================================================================= // Opus 编码器 // ============================================================================= diff --git a/nativelib/src/main/cpp/napi_init.cpp b/nativelib/src/main/cpp/napi_init.cpp index d6ce3c9..e89161d 100644 --- a/nativelib/src/main/cpp/napi_init.cpp +++ b/nativelib/src/main/cpp/napi_init.cpp @@ -86,6 +86,9 @@ static napi_value Init(napi_env env, napi_value exports) { { "sendMicrophoneOpusData", nullptr, MoonBridge_SendMicrophoneOpusData, nullptr, nullptr, nullptr, napi_default, nullptr }, { "isMicrophoneEncryptionEnabled", nullptr, MoonBridge_IsMicrophoneEncryptionEnabled, nullptr, nullptr, nullptr, napi_default, nullptr }, + // 剪贴板同步(Sunshine protocol extension) + { "sendClipboardData", nullptr, MoonBridge_SendClipboardData, nullptr, nullptr, nullptr, napi_default, nullptr }, + // Opus 编码器 { "opusEncoderCreate", nullptr, MoonBridge_OpusEncoderCreate, nullptr, nullptr, nullptr, napi_default, nullptr }, { "opusEncoderEncode", nullptr, MoonBridge_OpusEncoderEncode, nullptr, nullptr, nullptr, napi_default, nullptr }, From 046b11e33c411e08414c7d4f2f0ffbbda63015a1 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Mon, 11 May 2026 16:49:58 +0800 Subject: [PATCH 02/11] fix(harmonyos): address clipboard sync review feedback - restart clipboard sync after reconnect and gate startup on READ_PASTEBOARD - reuse pasteboard listener and roll back pendingSelfWrites on write failure - align clipboard settings keys with settings_ naming - clamp inbound clipboard payload size and compute typed array byte lengths correctly - document one-time image downgrade toast UX --- docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md | 5 + entry/src/main/ets/pages/StreamPage.ets | 106 +++++++++++++----- .../src/main/ets/service/SettingsService.ets | 4 +- .../clipboard/ClipboardSyncService.ets | 14 ++- nativelib/src/main/cpp/callbacks.cpp | 15 ++- nativelib/src/main/cpp/moonlight_bridge.cpp | 38 ++++++- 6 files changed, 144 insertions(+), 38 deletions(-) diff --git a/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md b/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md index e822fe4..e7531d0 100644 --- a/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md +++ b/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md @@ -156,6 +156,11 @@ sysBoard.off('update', listener) - 设置页保留图片同步入口 - 协议仍识别 PNG kind - 本地或远端一旦进入图片分支,只提示一次不支持 + - 当前 `StreamPage` 中的用户可见文案为:`剪贴板同步错误: 当前 HarmonyOS 兼容 SDK 下未验证图片剪贴板 API,图片同步暂时禁用` + - Optional English reference: `Clipboard sync error: image clipboard sync is temporarily disabled because the current HarmonyOS-compatible SDK has no verified image clipboard API path` + - 展示方式为 `ToastQueue` 串行调度的系统 Toast(底层是 `promptAction.showToast`),时长 `2000ms`,属于轻量非阻塞提示,不会弹模态框打断串流 + - “只提示一次”的作用域是**单个 `ClipboardSyncService` 实例生命周期内一次**:由服务内的 `imageSupportWarned` 内存标志控制,不写入本地持久化存储 + - 该标志会在当前服务实例被停止并释放后失效;当前接线方式下,重新开始一次串流会创建新服务实例,因此新串流会重新允许提示一次 - 不调用未验证图片剪贴板 API 这样做的好处是: diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index f346461..30156e9 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -16,7 +16,7 @@ import { router, LengthMetrics } from '@kit.ArkUI'; import { display } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; -import { common } from '@kit.AbilityKit'; +import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; import { displaySync } from '@kit.ArkGraphics2D'; import { VirtualControllerOverlay } from '../components/virtual/VirtualController'; import { PerformanceOverlay } from '../components/PerformanceOverlay'; @@ -673,11 +673,7 @@ struct StreamPage { aboutToDisappear(): void { console.info('StreamPage aboutToDisappear - cleaning up'); - // 停止剪贴板同步 - if (this.clipboardSyncService) { - this.clipboardSyncService.stop(); - this.clipboardSyncService = null; - } + this.stopClipboardSync(); // 取消 Network Boost 场景监听 if (this.netSceneListener) { @@ -1301,11 +1297,7 @@ struct StreamPage { // 设置连接终止回调 this.viewModel.setConnectionTerminatedCallback((errorCode: number) => { - // 停止剪贴板同步 - if (this.clipboardSyncService) { - this.clipboardSyncService.stop(); - this.clipboardSyncService = null; - } + this.stopClipboardSync(); this.lifecycleManager.handleConnectionTerminated(errorCode); }); @@ -1318,24 +1310,7 @@ struct StreamPage { await this.launchStream(); this.streamStartedAtMs = Date.now(); - // 启动剪贴板同步 - try { - const enableClipboardSyncText = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); - const enableClipboardSyncImage = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); - if (enableClipboardSyncText || enableClipboardSyncImage) { - const clipboardConfig = new ClipboardSyncConfig(); - clipboardConfig.enableSyncText = enableClipboardSyncText; - clipboardConfig.enableSyncImage = enableClipboardSyncImage; - const clipboardObserver: ClipboardSyncObserver = new StreamClipboardObserver(); - this.clipboardSyncService = new ClipboardSyncService( - clipboardConfig, - clipboardObserver - ); - this.clipboardSyncService.start(); - } - } catch (error) { - console.error('Failed to initialize clipboard sync:', error); - } + await this.startClipboardSync(); // 超分辨率状态 Toast this.showUpscaleToast(); @@ -1441,6 +1416,72 @@ struct StreamPage { await this.windowManager?.restoreWindow(); } + private stopClipboardSync(): void { + if (!this.clipboardSyncService) { + return; + } + + this.clipboardSyncService.stop(); + this.clipboardSyncService = null; + } + + private async ensureClipboardPermissionGranted(): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const bundleInfo = await bundleManager.getBundleInfoForSelf( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION + ); + const tokenId = bundleInfo.appInfo.accessTokenId; + const permission: Permissions = 'ohos.permission.READ_PASTEBOARD'; + + let granted = atManager.checkAccessTokenSync(tokenId, permission) === + abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + + if (!granted) { + console.info('[StreamPage] 请求剪贴板权限...'); + const result = await atManager.requestPermissionsFromUser(this.context, [permission]); + granted = result.authResults.length > 0 && + result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + } + + return granted; + } catch (error) { + console.error('[StreamPage] 检查剪贴板权限失败:', error); + return false; + } + } + + private async startClipboardSync(): Promise { + this.stopClipboardSync(); + + try { + const enableClipboardSyncText = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); + const enableClipboardSyncImage = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + if (!enableClipboardSyncText && !enableClipboardSyncImage) { + return; + } + + const granted = await this.ensureClipboardPermissionGranted(); + if (!granted) { + ToastQueue.show({ message: '未授予剪贴板权限,无法启用剪贴板同步', duration: 2000 }); + return; + } + + const clipboardConfig = new ClipboardSyncConfig(); + clipboardConfig.enableSyncText = enableClipboardSyncText; + clipboardConfig.enableSyncImage = enableClipboardSyncImage; + const clipboardObserver: ClipboardSyncObserver = new StreamClipboardObserver(); + this.clipboardSyncService = new ClipboardSyncService( + clipboardConfig, + clipboardObserver + ); + this.clipboardSyncService.start(); + } catch (error) { + console.error('Failed to initialize clipboard sync:', error); + this.stopClipboardSync(); + } + } + /** * 战报展示期间,真正的停流/退游戏在后台继续执行;关闭战报前再等待其收尾。 */ @@ -1515,6 +1556,8 @@ struct StreamPage { return; } + this.stopClipboardSync(); + if (!this.endStreamReportEnabled) { await this.prepareStreamEndUi(); await this.exitStreamAndReturn(); @@ -1543,6 +1586,8 @@ struct StreamPage { return; } + this.stopClipboardSync(); + const report = this.endStreamReportEnabled ? this.buildCurrentStreamReport() : null; this.prepareStreamEndUiImmediate(); @@ -1574,6 +1619,8 @@ struct StreamPage { async reconnectStreaming(): Promise { console.info('[StreamPage] 开始重连串流...'); try { + this.stopClipboardSync(); + // 先停止当前会话(静默,不导航不显示 toast) await this.viewModel.stopStreaming(); console.info('[StreamPage] 旧会话已停止,开始重新连接...'); @@ -1584,6 +1631,7 @@ struct StreamPage { // 获取 surfaceId → 设置帧率 → 启动串流 await this.launchStream(); this.streamStartedAtMs = Date.now(); + await this.startClipboardSync(); console.info('[StreamPage] 重连串流成功'); ToastQueue.show({ message: '已重新连接' }); diff --git a/entry/src/main/ets/service/SettingsService.ets b/entry/src/main/ets/service/SettingsService.ets index 452c875..926c7e4 100644 --- a/entry/src/main/ets/service/SettingsService.ets +++ b/entry/src/main/ets/service/SettingsService.ets @@ -61,8 +61,8 @@ export class SettingsKeys { static readonly AUDIO_COMPAT_MODE: string = 'settings_audio_compat_mode'; // 剪贴板同步设置 - static readonly ENABLE_CLIPBOARD_SYNC_TEXT: string = 'enableClipboardSyncText'; - static readonly ENABLE_CLIPBOARD_SYNC_IMAGE: string = 'enableClipboardSyncImage'; + static readonly ENABLE_CLIPBOARD_SYNC_TEXT: string = 'settings_enable_clipboard_sync_text'; + static readonly ENABLE_CLIPBOARD_SYNC_IMAGE: string = 'settings_enable_clipboard_sync_image'; // 输入 - 手柄设置 static readonly ENABLE_VIBRATION: string = 'settings_enable_vibration'; diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets index cd0eb7f..bfbdf7d 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -99,11 +99,14 @@ export class ClipboardSyncService implements ClipboardSyncListener { private setupPasteboardListener(): void { try { const sysBoard = pasteboard.getSystemPasteboard() - const listener: PasteboardUpdateListener = (): void => { - void this.onPasteboardChanged() + if (!this.pasteboardEventListener) { + this.pasteboardEventListener = (): void => { + void this.onPasteboardChanged() + } + } else { + sysBoard.off('update', this.pasteboardEventListener) } - this.pasteboardEventListener = listener - sysBoard.on('update', listener) + sysBoard.on('update', this.pasteboardEventListener) } catch (err) { const message = formatError(err as Object) logError(`Failed to setup pasteboard listener: ${message}`) @@ -215,6 +218,9 @@ export class ClipboardSyncService implements ClipboardSyncListener { const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) sysBoard.setDataSync(pasteData) } catch (err) { + if (this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } const message = formatError(err as Object) logError(`Failed to set local clipboard: ${message}`) this.observer?.onError?.(`写入本地剪贴板失败:${message}`) diff --git a/nativelib/src/main/cpp/callbacks.cpp b/nativelib/src/main/cpp/callbacks.cpp index 0031dfc..1bb8b3c 100644 --- a/nativelib/src/main/cpp/callbacks.cpp +++ b/nativelib/src/main/cpp/callbacks.cpp @@ -1003,6 +1003,9 @@ void BridgeClResolutionChanged(unsigned int width, unsigned int height) { } void BridgeClClipboardData(const char* data, int length) { + static constexpr int kClipboardWireHeaderSize = 10; + static constexpr uint32_t kMaxClipboardPayloadBytes = 65500 - kClipboardWireHeaderSize; + if (g_connCallbacks.tsfn_clipboardData == nullptr || data == nullptr || length < 10) { return; } @@ -1020,7 +1023,7 @@ void BridgeClClipboardData(const char* data, int length) { (static_cast(frame[7]) << 8) | (static_cast(frame[8]) << 16) | (static_cast(frame[9]) << 24); - const int availablePayloadLength = length - 10; + const int availablePayloadLength = length - kClipboardWireHeaderSize; if (version != 1) { OH_LOG_WARN(LOG_APP, "BridgeClClipboardData: unsupported version=%{public}u", version); @@ -1033,6 +1036,14 @@ void BridgeClClipboardData(const char* data, int length) { return; } + if (payloadLength > kMaxClipboardPayloadBytes || + static_cast(availablePayloadLength) > kMaxClipboardPayloadBytes) { + OH_LOG_WARN(LOG_APP, + "BridgeClClipboardData: payload too large len=%{public}u available=%{public}d max=%{public}u", + payloadLength, availablePayloadLength, kMaxClipboardPayloadBytes); + return; + } + CallbackData* cbData = new CallbackData(); memset(cbData, 0, sizeof(*cbData)); cbData->intParams[0] = static_cast(kind); @@ -1046,7 +1057,7 @@ void BridgeClClipboardData(const char* data, int length) { OH_LOG_ERROR(LOG_APP, "BridgeClClipboardData: malloc failed for %{public}d bytes", availablePayloadLength); return; } - memcpy(cbData->ptrParam, frame + 10, availablePayloadLength); + memcpy(cbData->ptrParam, frame + kClipboardWireHeaderSize, availablePayloadLength); } napi_status st = napi_call_threadsafe_function(g_connCallbacks.tsfn_clipboardData, cbData, napi_tsfn_blocking); diff --git a/nativelib/src/main/cpp/moonlight_bridge.cpp b/nativelib/src/main/cpp/moonlight_bridge.cpp index facbf06..ea78d83 100644 --- a/nativelib/src/main/cpp/moonlight_bridge.cpp +++ b/nativelib/src/main/cpp/moonlight_bridge.cpp @@ -168,6 +168,28 @@ static bool GetBool(napi_env env, napi_value value, bool* result) { return true; } +static size_t GetTypedArrayElementSize(napi_typedarray_type type) { + switch (type) { + case napi_int8_array: + case napi_uint8_array: + case napi_uint8_clamped_array: + return 1; + case napi_int16_array: + case napi_uint16_array: + return 2; + case napi_int32_array: + case napi_uint32_array: + case napi_float32_array: + return 4; + case napi_float64_array: + case napi_bigint64_array: + case napi_biguint64_array: + return 8; + default: + return 0; + } +} + static bool GetByteArrayData(napi_env env, napi_value value, void** data, size_t* length) { bool isTypedArray = false; napi_status status = napi_is_typedarray(env, value, &isTypedArray); @@ -175,7 +197,21 @@ static bool GetByteArrayData(napi_env env, napi_value value, void** data, size_t napi_typedarray_type type; napi_value arrayBuffer; size_t byteOffset = 0; - return napi_get_typedarray_info(env, value, &type, length, data, &arrayBuffer, &byteOffset) == napi_ok; + size_t elementCount = 0; + status = napi_get_typedarray_info(env, value, &type, &elementCount, data, &arrayBuffer, &byteOffset); + if (status != napi_ok) { + return false; + } + + const size_t elementSize = GetTypedArrayElementSize(type); + if (elementSize == 0) { + *data = nullptr; + *length = 0; + return false; + } + + *length = elementCount * elementSize; + return true; } bool isArrayBuffer = false; From f2c4ce39fa6a57e6f2f433eb4952d19051853b75 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Mon, 11 May 2026 16:53:57 +0800 Subject: [PATCH 03/11] fix(harmonyos): gate custom key clipboard reads on permission - verify StreamPage clipboard sync permission gating is already present - leave SettingsPageV2 unchanged because it only writes clipboard data - request READ_PASTEBOARD in CustomKeyOverlay before paste/import reads --- .../main/ets/components/CustomKeyOverlay.ets | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/entry/src/main/ets/components/CustomKeyOverlay.ets b/entry/src/main/ets/components/CustomKeyOverlay.ets index 66b05fa..c4c5273 100644 --- a/entry/src/main/ets/components/CustomKeyOverlay.ets +++ b/entry/src/main/ets/components/CustomKeyOverlay.ets @@ -48,7 +48,7 @@ import { image } from '@kit.ImageKit'; import { pasteboard } from '@kit.BasicServicesKit'; import { systemShare } from '@kit.ShareKit'; import { fileIo, fileUri } from '@kit.CoreFileKit'; -import { common } from '@kit.AbilityKit'; +import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; import { OV_ACCENT_BLUE, OV_TEXT_BRIGHT, OV_TEXT_MEDIUM, OV_TEXT_DIM, OV_TEXT_WHITE, OV_TEXT_SUBTLE, OV_BG_TRACK, OV_BG_CARD_LIGHTER, @@ -2063,8 +2063,40 @@ export struct CustomKeyOverlay { } /** 从剪贴板粘贴导入 */ + private async ensureClipboardReadPermissionGranted(): Promise { + try { + const context = getContext(this) as common.UIAbilityContext; + const atManager = abilityAccessCtrl.createAtManager(); + const bundleInfo = await bundleManager.getBundleInfoForSelf( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION + ); + const tokenId = bundleInfo.appInfo.accessTokenId; + const permission: Permissions = 'ohos.permission.READ_PASTEBOARD'; + + let granted = atManager.checkAccessTokenSync(tokenId, permission) === + abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + + if (!granted) { + const result = await atManager.requestPermissionsFromUser(context, [permission]); + granted = result.authResults.length > 0 && + result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + } + + return granted; + } catch (error) { + console.error('[CustomKeyOverlay] 检查剪贴板权限失败:', JSON.stringify(error)); + return false; + } + } + private async pasteFromClipboard(): Promise { try { + const granted = await this.ensureClipboardReadPermissionGranted(); + if (!granted) { + ToastQueue.show({ message: '未授予剪贴板权限', duration: 1500 }); + return; + } + const sysBoard = pasteboard.getSystemPasteboard(); const pasteData = await sysBoard.getData(); const text = pasteData.getPrimaryText(); From cf0749b277c7a4608959fce6bab4cdf5f7600460 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Mon, 11 May 2026 17:57:50 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix(harmonyos):=20=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=89=AA=E8=B4=B4=E6=9D=BF=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复客户端到主机文本剪贴板发送桥接 - 文本同步优先于图片,避免旧图片抢占 - 设置页禁用图片同步开关并清理旧配置 - 串流启动时强制关闭图片同步 --- entry/src/main/ets/pages/SettingsPageV2.ets | 53 +++---- entry/src/main/ets/pages/StreamPage.ets | 6 +- .../clipboard/ClipboardSyncService.ets | 132 ++++++++++++++---- .../main/ets/service/jni/ClipboardBridge.ets | 19 +-- 4 files changed, 134 insertions(+), 76 deletions(-) diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index 793735b..eea8c07 100644 --- a/entry/src/main/ets/pages/SettingsPageV2.ets +++ b/entry/src/main/ets/pages/SettingsPageV2.ets @@ -272,7 +272,6 @@ struct SettingsPageV2 { private sidebarGroups: SidebarGroupMeta[] = [ { id: 'video', icon: $r('sys.symbol.video_fill'), title: '视频' }, { id: 'audio', icon: $r('sys.symbol.speaker_wave_2_fill'), title: '音频' }, - { id: 'clipboard', icon: $r('sys.symbol.share'), title: '剪贴板同步' }, { id: 'gamepad', icon: $r('sys.symbol.gamecontroller_fill'), title: '手柄' }, { id: 'usb_driver', icon: $r('sys.symbol.charg_cable'), title: 'USB 驱动' }, { id: 'osc', icon: $r('sys.symbol.gamecontroller'), title: '屏幕控制器' }, @@ -500,7 +499,11 @@ struct SettingsPageV2 { // 剪贴板同步设置 this.enableClipboardSyncText = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); - this.enableClipboardSyncImage = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + const clipboardImageSyncEnabled = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + if (clipboardImageSyncEnabled) { + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + } + this.enableClipboardSyncImage = false; // 输入 - 手柄设置 this.enableVibration = await this.loadBoolean(SettingsKeys.ENABLE_VIBRATION, true); @@ -1313,35 +1316,6 @@ struct SettingsPageV2 { ] }) - // 剪贴板同步设置(仅 Sunshine) - this.SettingsGroup({ - id: 'clipboard', - icon: $r('sys.symbol.share'), - title: '剪贴板同步', - items: [ - { - title: '同步文本', - subtitle: 'Sunshine only - 与主机同步剪贴板文本', - type: 'toggle', - value: this.enableClipboardSyncText, - action: () => { - this.enableClipboardSyncText = !this.enableClipboardSyncText; - this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, this.enableClipboardSyncText); - } - }, - { - title: '同步图片', - subtitle: 'Sunshine only - 同步 PNG 格式的剪贴板图片', - type: 'toggle', - value: this.enableClipboardSyncImage, - action: () => { - this.enableClipboardSyncImage = !this.enableClipboardSyncImage; - this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, this.enableClipboardSyncImage); - } - } - ] - }) - // 手柄设置 this.SettingsGroup({ id: 'gamepad', @@ -1825,6 +1799,23 @@ struct SettingsPageV2 { this.saveSetting(SettingsKeys.LOCK_SCREEN_AFTER_DISCONNECT, this.lockScreenAfterDisconnect); } }, + { + title: '剪贴板同步文本', + subtitle: 'Sunshine only - 与主机双向同步剪贴板文本', + type: 'toggle', + value: this.enableClipboardSyncText, + action: () => { + this.enableClipboardSyncText = !this.enableClipboardSyncText; + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, this.enableClipboardSyncText); + } + }, + { + title: '剪贴板同步图片', + subtitle: '暂时禁用 - 图片同步链路仍在排查中', + type: 'toggle', + value: this.enableClipboardSyncImage, + disabled: true + }, { title: '仅控制模式', subtitle: '禁用音视频,仅传输输入', diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 30156e9..8864853 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -1456,7 +1456,11 @@ struct StreamPage { try { const enableClipboardSyncText = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); - const enableClipboardSyncImage = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + const enableClipboardSyncImagePref = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + if (enableClipboardSyncImagePref) { + await PreferencesUtil.put(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); + } + const enableClipboardSyncImage = false; if (!enableClipboardSyncText && !enableClipboardSyncImage) { return; } diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets index bfbdf7d..6cf3a1d 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -1,4 +1,5 @@ import { pasteboard, BusinessError } from '@kit.BasicServicesKit' +import { image } from '@kit.ImageKit' import { StringUtil } from '../../utils/StringUtil' import { MoonBridge } from '../jni/ClipboardBridge' import { @@ -13,6 +14,10 @@ const MAX_CLIPBOARD_FRAME_BYTES = 65500 - ClipboardWireFrame.HEADER_SIZE type PasteboardUpdateListener = () => void +interface PasteDataPixelMapAccessor { + getPrimaryPixelMap(): image.PixelMap +} + export interface ClipboardSyncObserver { onError?(error: string): void } @@ -43,9 +48,6 @@ function formatError(err: Object): string { /** * Manages clipboard synchronization between HarmonyOS and Sunshine host. - * - * 当前 SDK 已验证文本剪贴板 API;图片剪贴板相关接口在本项目兼容 SDK 下不可用, - * 因此图片同步暂时显式降级为 no-op,并保留协议层入口以便未来恢复。 */ export class ClipboardSyncService implements ClipboardSyncListener { private config: ClipboardSyncConfig @@ -54,7 +56,6 @@ export class ClipboardSyncService implements ClipboardSyncListener { private pendingSelfWrites: number = 0 private pasteboardEventListener: PasteboardUpdateListener | null = null private isRunning: boolean = false - private imageSupportWarned: boolean = false constructor(config?: ClipboardSyncConfig, observer?: ClipboardSyncObserver) { this.config = config ? config : new ClipboardSyncConfig() @@ -71,10 +72,6 @@ export class ClipboardSyncService implements ClipboardSyncListener { return } - if (this.config.enableSyncImage) { - this.warnImageSyncUnsupported('start') - } - this.setupPasteboardListener() this.registerWithMoonBridge() this.isRunning = true @@ -142,30 +139,107 @@ export class ClipboardSyncService implements ClipboardSyncListener { return } - if (!this.config.enableSyncText) { + if (!this.config.enableSyncText && !this.config.enableSyncImage) { return } try { const sysBoard = pasteboard.getSystemPasteboard() const pasteData = await sysBoard.getData() - const text = pasteData.getPrimaryText() - if (!text || text.length === 0) { + const text = this.config.enableSyncText ? pasteData.getPrimaryText() : '' + + if (this.config.enableSyncText && text && text.length > 0) { + this.sendPayload( + ClipboardWireFrame.KIND_TEXT, + this.generateToken(), + StringUtil.stringToUint8Array(text) + ) + return + } + + if (this.config.enableSyncImage) { + const primaryPixelMap = this.tryGetPrimaryPixelMap(pasteData) + if (primaryPixelMap) { + await this.sendImagePayload(primaryPixelMap) + return + } + } + + if (!this.config.enableSyncText) { + return + } + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to read local clipboard: ${message}`) + this.observer?.onError?.(`读取本地剪贴板失败:${message}`) + } + } + + private tryGetPrimaryPixelMap(pasteData: pasteboard.PasteData): image.PixelMap | null { + try { + const accessor = pasteData as PasteDataPixelMapAccessor + const pixelMap = accessor.getPrimaryPixelMap() + return pixelMap ? pixelMap : null + } catch (_err) { + return null + } + } + + private async sendImagePayload(pixelMap: image.PixelMap): Promise { + let packer: image.ImagePacker | null = null + try { + packer = image.createImagePacker() + const packed = await packer.packing(pixelMap, { format: 'image/png', quality: 100 }) as ArrayBuffer + const payload = new Uint8Array(packed) + + if (payload.length === 0) { + return + } + + if (payload.length > MAX_CLIPBOARD_FRAME_BYTES) { + logWarn(`Clipboard PNG payload size=${payload.length} exceeds limit, dropping`) + this.observer?.onError?.('剪贴板图片过大,已跳过同步') return } this.sendPayload( - ClipboardWireFrame.KIND_TEXT, + ClipboardWireFrame.KIND_PNG, this.generateToken(), - StringUtil.stringToUint8Array(text) + payload ) } catch (err) { const message = formatError(err as Object) - logError(`Failed to read local clipboard: ${message}`) - this.observer?.onError?.(`读取本地剪贴板失败:${message}`) + logError(`Failed to encode clipboard image: ${message}`) + this.observer?.onError?.(`读取本地剪贴板图片失败:${message}`) + } finally { + if (packer) { + packer.release() + } + } + } + + private toExactArrayBuffer(data: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(data.length) + copy.set(data) + return copy.buffer + } + + private async decodePngPayload(payload: Uint8Array): Promise { + const imageSource: image.ImageSource = image.createImageSource(this.toExactArrayBuffer(payload)) + try { + return await imageSource.createPixelMap({}) + } finally { + imageSource.release() } } + private writePixelMapToPasteboard(pixelMap: image.PixelMap): void { + const createPixelMapData = pasteboard.createData as (mimeType: string, value: image.PixelMap) => pasteboard.PasteData + const sysBoard = pasteboard.getSystemPasteboard() + const pasteData = createPixelMapData(pasteboard.MIMETYPE_PIXELMAP, pixelMap) + sysBoard.setDataSync(pasteData) + } + private sendPayload(kind: number, token: number, payload: Uint8Array): void { if (payload.length > MAX_CLIPBOARD_FRAME_BYTES) { logWarn(`Clipboard payload kind=${kind} size=${payload.length} exceeds limit, dropping`) @@ -197,8 +271,21 @@ export class ClipboardSyncService implements ClipboardSyncListener { } if (kind === ClipboardWireFrame.KIND_PNG) { - if (this.config.enableSyncImage) { - this.warnImageSyncUnsupported('remote') + if (!this.config.enableSyncImage) { + return + } + + try { + const pixelMap = await this.decodePngPayload(payload) + this.pendingSelfWrites++ + this.writePixelMapToPasteboard(pixelMap) + } catch (err) { + if (this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } + const message = formatError(err as Object) + logError(`Failed to set local clipboard image: ${message}`) + this.observer?.onError?.(`写入本地剪贴板图片失败:${message}`) } return } @@ -254,15 +341,4 @@ export class ClipboardSyncService implements ClipboardSyncListener { private generateToken(): number { return Math.floor(Math.random() * 0x100000000) >>> 0 } - - private warnImageSyncUnsupported(source: string): void { - if (this.imageSupportWarned) { - return - } - - this.imageSupportWarned = true - const message = '当前 HarmonyOS 兼容 SDK 下未验证图片剪贴板 API,图片同步暂时禁用' - logWarn(`${message} (${source})`) - this.observer?.onError?.(message) - } } diff --git a/entry/src/main/ets/service/jni/ClipboardBridge.ets b/entry/src/main/ets/service/jni/ClipboardBridge.ets index 39eab9d..f349c2f 100644 --- a/entry/src/main/ets/service/jni/ClipboardBridge.ets +++ b/entry/src/main/ets/service/jni/ClipboardBridge.ets @@ -5,6 +5,7 @@ * through the Sunshine control stream (message type 0x5508). */ +import nativeLib from 'libmoonlight_nativelib.so' import { ClipboardSyncListener } from '../clipboard/ClipboardSyncConfig' /** @@ -74,8 +75,8 @@ export namespace MoonBridge { return SendErrorCode.INVALID_PAYLOAD } - // Call native method - return nativeSendClipboardData(frame) + // Call native method exported by libmoonlight_nativelib.so + return nativeLib.sendClipboardData(frame) as number } /** @@ -97,20 +98,6 @@ export namespace MoonBridge { } } - /** - * Native binding: Send clipboard frame data. - * - * This is called by sendClipboardData after building the complete wire frame. - * The native implementation will: - * 1. Validate the frame format - * 2. Check server version and capabilities - * 3. Send via control stream (0x5508 message) - * - * @param frame Complete wire frame (version + kind + token + length + payload) - * @returns SendErrorCode result - * @nativeMethod moonlight_jni.cpp: Java_com_limelight_nvstream_jni_MoonBridge_nativeSendClipboardData - */ - declare function nativeSendClipboardData(frame: Uint8Array): number } /** From 0bd4c1f92de290deae363bf991c0bff2ae593838 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 00:12:39 +0800 Subject: [PATCH 05/11] refactor(clipboard): drop NvHttp fallback paths and tighten plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NvHttp.uploadClipboardBlob/downloadClipboardBlob: 删除针对 Sunshine 不存在 的备选路径 (api/clipboard/blob, ?id= query 形式)。这些 fallback 每次都要 走完一轮 HTTPS 握手 + frp retry,污染日志且掩盖真实错误。Sunshine 是唯一 对接目标,路径固定 /api/v1/clipboard/blob。 - 收敛 upload response 解析为只读 id 字段,移除 ref/blobId/blob_id 等幻想 字段名兼容;非 JSON 响应直接抛错而非降级当作裸 id 使用。 - ClipboardBlobTransport.downloadBlob 接口由 (ref) 改为 (id),与 uploadBlob 返回字符串语义对称;transport 层无需 mime/size 元数据。 - ClipboardSyncService.onPasteboardChanged 删除末尾死代码。 - decodePngPayload 避免对已经是独占 buffer 的 Uint8Array 做多余拷贝。 --- .../clipboard/ClipboardBlobTransport.ets | 17 ++ .../clipboard/ClipboardSyncService.ets | 252 +++++++++++++++--- .../SunshineClipboardBlobTransport.ets | 22 ++ .../src/main/ets/service/streaming/NvHttp.ets | 158 ++++++++++- 4 files changed, 406 insertions(+), 43 deletions(-) create mode 100644 entry/src/main/ets/service/clipboard/ClipboardBlobTransport.ets create mode 100644 entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets diff --git a/entry/src/main/ets/service/clipboard/ClipboardBlobTransport.ets b/entry/src/main/ets/service/clipboard/ClipboardBlobTransport.ets new file mode 100644 index 0000000..b1eee53 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/ClipboardBlobTransport.ets @@ -0,0 +1,17 @@ +import { ClipboardBlobRef } from './ClipboardSyncConfig' + +/** + * Out-of-band clipboard blob transport for large clipboard payloads. + */ +export interface ClipboardBlobTransport { + /** + * Upload a clipboard blob and return a small reference payload that can be + * sent over the control stream as KIND_REF. + */ + uploadBlob(mime: string, data: Uint8Array): Promise + + /** + * Download a previously uploaded clipboard blob by id. + */ + downloadBlob(id: string): Promise +} diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets index 6cf3a1d..cb2e506 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -2,20 +2,27 @@ import { pasteboard, BusinessError } from '@kit.BasicServicesKit' import { image } from '@kit.ImageKit' import { StringUtil } from '../../utils/StringUtil' import { MoonBridge } from '../jni/ClipboardBridge' +import { ClipboardBlobTransport } from './ClipboardBlobTransport' import { ClipboardSyncConfig, ClipboardWireFrame, + ClipboardBlobRef, TokenEntry, ClipboardSyncListener } from './ClipboardSyncConfig' const TAG = 'ClipboardSyncService' const MAX_CLIPBOARD_FRAME_BYTES = 65500 - ClipboardWireFrame.HEADER_SIZE +const CLIPBOARD_TEXT_MIME = 'text/plain;charset=utf-8' +const CLIPBOARD_PNG_MIME = 'image/png' type PasteboardUpdateListener = () => void -interface PasteDataPixelMapAccessor { - getPrimaryPixelMap(): image.PixelMap +interface PasteDataRecordAccessor { + mimeType?: string + plainText?: string + pixelMap?: image.PixelMap + toPlainText?(): string } export interface ClipboardSyncObserver { @@ -52,14 +59,16 @@ function formatError(err: Object): string { export class ClipboardSyncService implements ClipboardSyncListener { private config: ClipboardSyncConfig private observer: ClipboardSyncObserver | null = null + private blobTransport: ClipboardBlobTransport | null = null private recentSentTokens: TokenEntry[] = [] private pendingSelfWrites: number = 0 private pasteboardEventListener: PasteboardUpdateListener | null = null private isRunning: boolean = false - constructor(config?: ClipboardSyncConfig, observer?: ClipboardSyncObserver) { + constructor(config?: ClipboardSyncConfig, observer?: ClipboardSyncObserver, blobTransport?: ClipboardBlobTransport | null) { this.config = config ? config : new ClipboardSyncConfig() this.observer = observer ? observer : null + this.blobTransport = blobTransport ? blobTransport : null } start(): void { @@ -146,28 +155,25 @@ export class ClipboardSyncService implements ClipboardSyncListener { try { const sysBoard = pasteboard.getSystemPasteboard() const pasteData = await sysBoard.getData() - const text = this.config.enableSyncText ? pasteData.getPrimaryText() : '' + const recordCount = pasteData.getRecordCount() + const text = this.config.enableSyncText ? this.tryGetPrimaryText(pasteData, recordCount) : '' if (this.config.enableSyncText && text && text.length > 0) { - this.sendPayload( - ClipboardWireFrame.KIND_TEXT, - this.generateToken(), - StringUtil.stringToUint8Array(text) - ) + logInfo(`Local clipboard changed: recordCount=${recordCount}, textLength=${text.length}`) + await this.sendTextPayload(text) return } if (this.config.enableSyncImage) { - const primaryPixelMap = this.tryGetPrimaryPixelMap(pasteData) + const primaryPixelMap = this.tryGetPrimaryPixelMap(pasteData, recordCount) if (primaryPixelMap) { + logInfo(`Local clipboard changed: recordCount=${recordCount}, detected pixelMap image`) await this.sendImagePayload(primaryPixelMap) return } } - if (!this.config.enableSyncText) { - return - } + logInfo(`Local clipboard changed: recordCount=${recordCount}, no supported text/image payload`) } catch (err) { const message = formatError(err as Object) logError(`Failed to read local clipboard: ${message}`) @@ -175,14 +181,52 @@ export class ClipboardSyncService implements ClipboardSyncListener { } } - private tryGetPrimaryPixelMap(pasteData: pasteboard.PasteData): image.PixelMap | null { + private async sendTextPayload(text: string): Promise { + const payload = StringUtil.stringToUint8Array(text) + await this.sendPayloadOrBlob(ClipboardWireFrame.KIND_TEXT, CLIPBOARD_TEXT_MIME, payload, '文本') + } + + private tryGetPrimaryText(pasteData: pasteboard.PasteData, recordCount: number): string { try { - const accessor = pasteData as PasteDataPixelMapAccessor - const pixelMap = accessor.getPrimaryPixelMap() - return pixelMap ? pixelMap : null + const primaryText = pasteData.getPrimaryText() + if (primaryText && primaryText.length > 0) { + return primaryText + } + } catch (_err) { + } + + for (let i = 0; i < recordCount; i++) { + try { + const record = pasteData.getRecord(i) as PasteDataRecordAccessor + if (typeof record.plainText === 'string' && record.plainText.length > 0) { + return record.plainText + } + if (record.toPlainText) { + const text = record.toPlainText() + if (text && text.length > 0) { + return text + } + } + } catch (_err) { + } + } + + return '' + } + + private tryGetPrimaryPixelMap(pasteData: pasteboard.PasteData, recordCount: number): image.PixelMap | null { + try { + for (let i = 0; i < recordCount; i++) { + const record = pasteData.getRecord(i) as PasteDataRecordAccessor + const pixelMap = record.pixelMap + if (pixelMap) { + return pixelMap + } + } } catch (_err) { - return null } + + return null } private async sendImagePayload(pixelMap: image.PixelMap): Promise { @@ -196,17 +240,7 @@ export class ClipboardSyncService implements ClipboardSyncListener { return } - if (payload.length > MAX_CLIPBOARD_FRAME_BYTES) { - logWarn(`Clipboard PNG payload size=${payload.length} exceeds limit, dropping`) - this.observer?.onError?.('剪贴板图片过大,已跳过同步') - return - } - - this.sendPayload( - ClipboardWireFrame.KIND_PNG, - this.generateToken(), - payload - ) + await this.sendPayloadOrBlob(ClipboardWireFrame.KIND_PNG, CLIPBOARD_PNG_MIME, payload, '图片') } catch (err) { const message = formatError(err as Object) logError(`Failed to encode clipboard image: ${message}`) @@ -218,14 +252,47 @@ export class ClipboardSyncService implements ClipboardSyncListener { } } - private toExactArrayBuffer(data: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(data.length) - copy.set(data) - return copy.buffer + private async sendPayloadOrBlob(kind: number, mime: string, payload: Uint8Array, displayName: string): Promise { + if (payload.length === 0) { + return + } + + if (payload.length <= MAX_CLIPBOARD_FRAME_BYTES) { + this.sendPayload(kind, this.generateToken(), payload) + return + } + + if (!this.blobTransport) { + logWarn(`Clipboard ${displayName} payload size=${payload.length} exceeds limit and blob transport is unavailable`) + this.observer?.onError?.(`剪贴板${displayName}过大,已跳过同步`) + return + } + + try { + const ref = await this.blobTransport.uploadBlob(mime, payload) + const refPayload = ClipboardWireFrame.buildRefPayload(ref) + if (refPayload.length > MAX_CLIPBOARD_FRAME_BYTES) { + logWarn(`Clipboard REF payload size=${refPayload.length} exceeds limit, dropping`) + this.observer?.onError?.(`剪贴板${displayName}引用过大,已跳过同步`) + return + } + + logInfo(`Uploaded clipboard ${displayName} blob id=${ref.id} size=${ref.size}`) + this.sendPayload(ClipboardWireFrame.KIND_REF, this.generateToken(), refPayload) + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to upload clipboard ${displayName} blob: ${message}`) + this.observer?.onError?.(`上传剪贴板${displayName}失败:${message}`) + } } private async decodePngPayload(payload: Uint8Array): Promise { - const imageSource: image.ImageSource = image.createImageSource(this.toExactArrayBuffer(payload)) + // ImageSource 需要一份独立的 ArrayBuffer。只有在入参 byteOffset != 0 或 + // byteLength != buffer.byteLength 时才必须拷贝;其他场景直接复用 + // payload.buffer 避免多余拷贝。 + const isExact = payload.byteOffset === 0 && payload.byteLength === payload.buffer.byteLength + const arrayBuffer = isExact ? payload.buffer : payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) + const imageSource: image.ImageSource = image.createImageSource(arrayBuffer) try { return await imageSource.createPixelMap({}) } finally { @@ -270,6 +337,11 @@ export class ClipboardSyncService implements ClipboardSyncListener { return } + if (kind === ClipboardWireFrame.KIND_REF) { + await this.applyRemoteBlobReference(payload) + return + } + if (kind === ClipboardWireFrame.KIND_PNG) { if (!this.config.enableSyncImage) { return @@ -301,9 +373,7 @@ export class ClipboardSyncService implements ClipboardSyncListener { } this.pendingSelfWrites++ - const sysBoard = pasteboard.getSystemPasteboard() - const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) - sysBoard.setDataSync(pasteData) + this.writeTextToPasteboard(text) } catch (err) { if (this.pendingSelfWrites > 0) { this.pendingSelfWrites-- @@ -314,6 +384,114 @@ export class ClipboardSyncService implements ClipboardSyncListener { } } + private async applyRemoteBlobReference(payload: Uint8Array): Promise { + const ref = ClipboardWireFrame.parseRefPayload(payload) + if (!ref) { + logWarn('Ignoring malformed clipboard REF payload') + return + } + + if (!this.shouldDownloadBlobRef(ref)) { + return + } + + const blobData = await this.downloadClipboardBlob(ref) + if (!blobData) { + return + } + + if (this.isTextMime(ref.mime)) { + await this.applyRemoteTextBlob(blobData) + return + } + + if (this.isPngMime(ref.mime)) { + await this.applyRemotePngBlob(blobData) + return + } + + logInfo(`Ignoring unsupported clipboard blob mime=${ref.mime}`) + } + + private shouldDownloadBlobRef(ref: ClipboardBlobRef): boolean { + if (this.isTextMime(ref.mime)) { + return this.config.enableSyncText + } + if (this.isPngMime(ref.mime)) { + return this.config.enableSyncImage + } + return false + } + + private async downloadClipboardBlob(ref: ClipboardBlobRef): Promise { + if (!this.blobTransport) { + logWarn(`Clipboard blob transport unavailable for ref id=${ref.id}`) + this.observer?.onError?.('当前会话未配置剪贴板大文件传输') + return null + } + + try { + const data = await this.blobTransport.downloadBlob(ref.id) + if (ref.size > 0 && data.length !== ref.size) { + logWarn(`Clipboard blob size mismatch id=${ref.id} expected=${ref.size} actual=${data.length}`) + } + return data + } catch (err) { + const message = formatError(err as Object) + logError(`Failed to download clipboard blob id=${ref.id}: ${message}`) + this.observer?.onError?.(`下载远端剪贴板内容失败:${message}`) + return null + } + } + + private async applyRemoteTextBlob(payload: Uint8Array): Promise { + try { + const text = StringUtil.uint8ArrayToString(payload) + if (text.length === 0) { + return + } + + this.pendingSelfWrites++ + this.writeTextToPasteboard(text) + } catch (err) { + if (this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } + const message = formatError(err as Object) + logError(`Failed to set local clipboard text blob: ${message}`) + this.observer?.onError?.(`写入本地剪贴板失败:${message}`) + } + } + + private async applyRemotePngBlob(payload: Uint8Array): Promise { + try { + const pixelMap = await this.decodePngPayload(payload) + this.pendingSelfWrites++ + this.writePixelMapToPasteboard(pixelMap) + } catch (err) { + if (this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } + const message = formatError(err as Object) + logError(`Failed to set local clipboard image blob: ${message}`) + this.observer?.onError?.(`写入本地剪贴板图片失败:${message}`) + } + } + + private writeTextToPasteboard(text: string): void { + const sysBoard = pasteboard.getSystemPasteboard() + const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text) + sysBoard.setDataSync(pasteData) + } + + private isTextMime(mime: string): boolean { + return mime.startsWith('text/') + } + + private isPngMime(mime: string): boolean { + return mime === CLIPBOARD_PNG_MIME + } + private isHostEcho(token: number): boolean { const now = Date.now() this.recentSentTokens = this.recentSentTokens.filter((entry: TokenEntry): boolean => { diff --git a/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets b/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets new file mode 100644 index 0000000..82e5aa4 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets @@ -0,0 +1,22 @@ +import { NvHttp } from '../streaming/NvHttp' +import { ClipboardBlobTransport } from './ClipboardBlobTransport' +import { ClipboardBlobRef } from './ClipboardSyncConfig' + +/** + * Clipboard blob transport backed by Sunshine HTTPS endpoints. + */ +export class SunshineClipboardBlobTransport implements ClipboardBlobTransport { + private readonly nvHttp: NvHttp + + constructor(nvHttp: NvHttp) { + this.nvHttp = nvHttp + } + + uploadBlob(mime: string, data: Uint8Array): Promise { + return this.nvHttp.uploadClipboardBlob(mime, data) + } + + downloadBlob(id: string): Promise { + return this.nvHttp.downloadClipboardBlob(id) + } +} diff --git a/entry/src/main/ets/service/streaming/NvHttp.ets b/entry/src/main/ets/service/streaming/NvHttp.ets index ec2af68..43fa9f2 100644 --- a/entry/src/main/ets/service/streaming/NvHttp.ets +++ b/entry/src/main/ets/service/streaming/NvHttp.ets @@ -20,6 +20,7 @@ import { CryptoUtil } from '../../utils/CryptoUtil'; import { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT } from '../../common/NetworkConstants'; import { parseAddressAndPort, formatAddressForUrl } from '../../utils/NetHelper'; import { selectBestAddress, NvHttpHost } from '../../model/ComputerInfo'; +import { ClipboardBlobRef } from '../clipboard/ClipboardSyncConfig'; /** * NvHTTP - 与 NVIDIA GameStream / Sunshine 服务器通信 @@ -78,6 +79,12 @@ interface RotateDisplayResponse { success?: boolean; } +interface ClipboardBlobUploadResponse { + id?: string; + mime?: string; + size?: number | string; +} + /** * getServerInfoRobust 选项 */ @@ -115,6 +122,10 @@ export class NvHttp { static readonly CONNECTION_TIMEOUT = 3000; static readonly LONG_CONNECTION_TIMEOUT = 5000; static readonly READ_TIMEOUT = 7000; + static readonly CLIPBOARD_BLOB_TIMEOUT = 30000; + static readonly CLIPBOARD_BLOB_CONNECT_TIMEOUT = 10000; + static readonly CLIPBOARD_BLOB_MAX_ATTEMPTS = 2; + static readonly CLIPBOARD_BLOB_RETRY_DELAY_MS = 1200; // getServerInfoRobust 默认参数(启动链路使用) static readonly DEFAULT_ROBUST_MAX_ATTEMPTS = 3; @@ -436,37 +447,172 @@ export class NvHttp { * @param timeout 超时时间 */ private async doHttpsRequestWithFrpRetry(path: string, query?: string, timeout: number = NvHttp.READ_TIMEOUT): Promise { + return this.doHttpsWithFrpRetry(path, query, timeout, (url: string): Promise => { + return this.doRequest(url, timeout, true); + }); + } + + private async doHttpsWithFrpRetry( + path: string, + query: string | undefined, + timeout: number, + request: (url: string) => Promise + ): Promise { const baseUrl = await this.getHttpsBaseUrl(); const url = this.buildUrl(baseUrl, path, query); try { - return await this.doRequest(url, timeout, true); + return await request(url); } catch (err) { const errMsg = String(err); - // 仅对「超时 + 自定义 HTTP 端口」做 frp 推算重试 if (!this.isTimeoutError(errMsg) || this.httpPort === NvHttp.DEFAULT_HTTP_PORT) { throw err instanceof Error ? err : new Error(errMsg); } + const guessedPort = this.httpPort - 5; if (guessedPort <= 0 || guessedPort === this.httpsPort) { throw err instanceof Error ? err : new Error(errMsg); } - // 避免重复请求:getHttpsBaseUrl 在自定义端口未缓存时已直接返回 httpPort-5, - // 此时 baseUrl 端口与 guessedPort 相同,重试无意义 + const usedPort = parseAddressAndPort(baseUrl.replace(/^https?:\/\//, '')).port; if (usedPort === guessedPort) { throw err instanceof Error ? err : new Error(errMsg); } + console.info(`NvHttp: ${path} HTTPS 超时,尝试推算端口 ${guessedPort} (httpPort=${this.httpPort} - 5)`); const retryBaseUrl = `https://${formatAddressForUrl(this.address)}:${guessedPort}`; const retryUrl = this.buildUrl(retryBaseUrl, path, query); - const response = await this.doRequest(retryUrl, timeout, true); - // 推算端口成功,缓存(后续请求自动使用) + const response = await request(retryUrl); this.httpsPort = guessedPort; console.info(`NvHttp: ${path} 推算 HTTPS 端口 ${guessedPort} 连接成功,已缓存`); return response; } } + private async doBinaryHttpsRequestWithFrpRetry(path: string, query?: string, timeout: number = NvHttp.CLIPBOARD_BLOB_TIMEOUT): Promise { + if (!this.hasCertificateFiles()) { + throw new Error('客户端证书缺失,无法下载剪贴板 blob'); + } + + return this.doClipboardBlobRequestWithRetry(`download ${path}`, (): Promise => { + return this.doHttpsWithFrpRetry(path, query, timeout, (url: string): Promise => { + return HttpClient.getBinaryWithClientCert( + url, + this.certFilePath, + this.keyFilePath, + { + connectTimeout: NvHttp.CLIPBOARD_BLOB_CONNECT_TIMEOUT, + transferTimeout: timeout + } + ); + }); + }); + } + + private async doBinaryHttpsPostWithFrpRetry( + path: string, + data: Uint8Array, + query?: string, + timeout: number = NvHttp.CLIPBOARD_BLOB_TIMEOUT, + headers?: Record + ): Promise { + if (!this.hasCertificateFiles()) { + throw new Error('客户端证书缺失,无法上传剪贴板 blob'); + } + + return this.doClipboardBlobRequestWithRetry(`upload ${path}`, (): Promise => { + return this.doHttpsWithFrpRetry(path, query, timeout, async (url: string): Promise => { + const response = await HttpClient.postBinaryWithClientCert( + url, + data, + this.certFilePath, + this.keyFilePath, + { + headers: headers, + connectTimeout: NvHttp.CLIPBOARD_BLOB_CONNECT_TIMEOUT, + transferTimeout: timeout + } + ); + return response.body; + }); + }); + } + + private async doClipboardBlobRequestWithRetry(label: string, request: () => Promise): Promise { + let lastErr: Error | null = null; + + for (let attempt = 1; attempt <= NvHttp.CLIPBOARD_BLOB_MAX_ATTEMPTS; attempt++) { + try { + const result = await request(); + if (attempt > 1) { + console.info(`NvHttp: clipboard blob ${label} 第 ${attempt} 次尝试成功`); + } + return result; + } catch (err) { + lastErr = err instanceof Error ? err : new Error(String(err)); + if (!NvHttp.isTransientNetworkError(lastErr) || attempt === NvHttp.CLIPBOARD_BLOB_MAX_ATTEMPTS) { + throw lastErr; + } + + console.warn(`NvHttp: clipboard blob ${label} 第 ${attempt}/${NvHttp.CLIPBOARD_BLOB_MAX_ATTEMPTS} 次失败,${NvHttp.CLIPBOARD_BLOB_RETRY_DELAY_MS}ms 后重试: ${lastErr.message}`); + await new Promise((resolve: () => void): void => { + setTimeout(resolve, NvHttp.CLIPBOARD_BLOB_RETRY_DELAY_MS); + }); + } + } + + throw lastErr ?? new Error(`clipboard blob ${label} 失败`); + } + + /** + * 上传剪贴板大 payload,返回可放入 KIND_REF 的小 JSON 引用。 + * 与 Sunshine 对接,路径固定为 /api/v1/clipboard/blob。 + */ + async uploadClipboardBlob(mime: string, data: Uint8Array): Promise { + const headers: Record = { + 'X-Clipboard-Mime': mime + } + const response = await this.doBinaryHttpsPostWithFrpRetry( + 'api/v1/clipboard/blob', data, undefined, NvHttp.CLIPBOARD_BLOB_TIMEOUT, headers) + return this.parseClipboardBlobUploadResponse(response, mime, data.length) + } + + /** + * 下载剪贴板 blob。Sunshine 使用 REST 风格 /api/v1/clipboard/blob/{id}。 + */ + async downloadClipboardBlob(id: string): Promise { + const encodedId = encodeURIComponent(id); + const data = await this.doBinaryHttpsRequestWithFrpRetry( + `api/v1/clipboard/blob/${encodedId}`, undefined, NvHttp.CLIPBOARD_BLOB_TIMEOUT) + return new Uint8Array(data) + } + + private parseClipboardBlobUploadResponse(response: string, fallbackMime: string, fallbackSize: number): ClipboardBlobRef { + const trimmed = response.trim(); + if (!trimmed) { + throw new Error('上传剪贴板 blob 返回空响应'); + } + + let parsed: ClipboardBlobUploadResponse; + try { + parsed = JSON.parse(trimmed) as ClipboardBlobUploadResponse; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`上传剪贴板 blob 响应非 JSON: ${message}`); + } + + const id = (parsed.id ?? '').trim(); + if (!id) { + throw new Error('上传剪贴板 blob 响应缺少 id'); + } + const size = Number(parsed.size); + return { + type: 'ref', + id: id, + mime: parsed.mime || fallbackMime, + size: Number.isFinite(size) && size >= 0 ? Math.floor(size) : fallbackSize + }; + } + /** * 获取服务器信息 * 优先使用 HTTPS(如果已配对),失败后尝试 HTTP From a2225a0f1f404a1db3ac133d44b9d375b645a5bf Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 00:24:14 +0800 Subject: [PATCH 06/11] =?UTF-8?q?refactor(clipboard):=20=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=20NvHttp=20=E5=8F=8D=E5=90=91=E4=BE=9D=E8=B5=96=20+=20?= =?UTF-8?q?=E6=94=B6=E7=B4=A7=20mime=20+=20DRY=20=E5=85=A5=E7=AB=99?= =?UTF-8?q?=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 反向依赖:NvHttp.uploadClipboardBlob 改为返回内部 ClipboardBlobUploadResult (id/mime/size 扁平结构),由 SunshineClipboardBlobTransport 在业务层包装为 ClipboardBlobRef。NvHttp 不再 import clipboard 模块类型,分层洁净。 - isTextMime 收紧:仅接受 text/* 且 charset 为 utf-8/utf8/us-ascii 或未指定, 避免 text/csv;charset=gbk 之类被按 UTF-8 解码造成乱码。 - decodePngPayload 始终拷贝 buffer,避免 ImageSource 异步持有 payload.buffer 在调用域之外的潜在 use-after-release 风险。 - 入站 4 处 (KIND_PNG/REF→PNG, KIND_TEXT/REF→TEXT) decode→pendingSelfWrites ++→write→catch--→onError 重复模式合并为 writeRemoteImagePayload / writeRemoteTextPayload 两个 helper,并修正自写计数泄漏 (claimedSelfWrite flag 确保仅在真正 ++ 后才 --)。 --- .../clipboard/ClipboardSyncService.ets | 85 +++++++++---------- .../SunshineClipboardBlobTransport.ets | 10 ++- .../src/main/ets/service/streaming/NvHttp.ets | 23 +++-- 3 files changed, 65 insertions(+), 53 deletions(-) diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets index cb2e506..4ca94a7 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -287,12 +287,11 @@ export class ClipboardSyncService implements ClipboardSyncListener { } private async decodePngPayload(payload: Uint8Array): Promise { - // ImageSource 需要一份独立的 ArrayBuffer。只有在入参 byteOffset != 0 或 - // byteLength != buffer.byteLength 时才必须拷贝;其他场景直接复用 - // payload.buffer 避免多余拷贝。 - const isExact = payload.byteOffset === 0 && payload.byteLength === payload.buffer.byteLength - const arrayBuffer = isExact ? payload.buffer : payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) - const imageSource: image.ImageSource = image.createImageSource(arrayBuffer) + // ImageSource 需要一份独立的 ArrayBuffer。OHOS 对 buffer 所有权语义 + // 文档不明,为避免 use-after-free / use-after-release 风险,始终拷贝。 + const copy = new Uint8Array(payload.length) + copy.set(payload) + const imageSource: image.ImageSource = image.createImageSource(copy.buffer) try { return await imageSource.createPixelMap({}) } finally { @@ -346,19 +345,7 @@ export class ClipboardSyncService implements ClipboardSyncListener { if (!this.config.enableSyncImage) { return } - - try { - const pixelMap = await this.decodePngPayload(payload) - this.pendingSelfWrites++ - this.writePixelMapToPasteboard(pixelMap) - } catch (err) { - if (this.pendingSelfWrites > 0) { - this.pendingSelfWrites-- - } - const message = formatError(err as Object) - logError(`Failed to set local clipboard image: ${message}`) - this.observer?.onError?.(`写入本地剪贴板图片失败:${message}`) - } + await this.writeRemoteImagePayload(payload, '直接帧') return } @@ -366,22 +353,7 @@ export class ClipboardSyncService implements ClipboardSyncListener { return } - try { - const text = StringUtil.uint8ArrayToString(payload) - if (text.length === 0) { - return - } - - this.pendingSelfWrites++ - this.writeTextToPasteboard(text) - } catch (err) { - if (this.pendingSelfWrites > 0) { - this.pendingSelfWrites-- - } - const message = formatError(err as Object) - logError(`Failed to set local clipboard: ${message}`) - this.observer?.onError?.(`写入本地剪贴板失败:${message}`) - } + await this.writeRemoteTextPayload(payload, '直接帧') } private async applyRemoteBlobReference(payload: Uint8Array): Promise { @@ -401,12 +373,12 @@ export class ClipboardSyncService implements ClipboardSyncListener { } if (this.isTextMime(ref.mime)) { - await this.applyRemoteTextBlob(blobData) + await this.writeRemoteTextPayload(blobData, `blob id=${ref.id}`) return } if (this.isPngMime(ref.mime)) { - await this.applyRemotePngBlob(blobData) + await this.writeRemoteImagePayload(blobData, `blob id=${ref.id}`) return } @@ -444,36 +416,47 @@ export class ClipboardSyncService implements ClipboardSyncListener { } } - private async applyRemoteTextBlob(payload: Uint8Array): Promise { + /** + * 解码并写入远端文本 payload 到本地剪贴板。统一处理 KIND_TEXT 直接帧 + * 与 KIND_REF 下载后的文本 blob。 + */ + private async writeRemoteTextPayload(payload: Uint8Array, source: string): Promise { + let claimedSelfWrite = false try { const text = StringUtil.uint8ArrayToString(payload) if (text.length === 0) { return } - this.pendingSelfWrites++ + claimedSelfWrite = true this.writeTextToPasteboard(text) } catch (err) { - if (this.pendingSelfWrites > 0) { + if (claimedSelfWrite && this.pendingSelfWrites > 0) { this.pendingSelfWrites-- } const message = formatError(err as Object) - logError(`Failed to set local clipboard text blob: ${message}`) + logError(`Failed to set local clipboard text (${source}): ${message}`) this.observer?.onError?.(`写入本地剪贴板失败:${message}`) } } - private async applyRemotePngBlob(payload: Uint8Array): Promise { + /** + * 解码并写入远端图片 payload 到本地剪贴板。统一处理 KIND_PNG 直接帧 + * 与 KIND_REF 下载后的图片 blob。 + */ + private async writeRemoteImagePayload(payload: Uint8Array, source: string): Promise { + let claimedSelfWrite = false try { const pixelMap = await this.decodePngPayload(payload) this.pendingSelfWrites++ + claimedSelfWrite = true this.writePixelMapToPasteboard(pixelMap) } catch (err) { - if (this.pendingSelfWrites > 0) { + if (claimedSelfWrite && this.pendingSelfWrites > 0) { this.pendingSelfWrites-- } const message = formatError(err as Object) - logError(`Failed to set local clipboard image blob: ${message}`) + logError(`Failed to set local clipboard image (${source}): ${message}`) this.observer?.onError?.(`写入本地剪贴板图片失败:${message}`) } } @@ -485,7 +468,19 @@ export class ClipboardSyncService implements ClipboardSyncListener { } private isTextMime(mime: string): boolean { - return mime.startsWith('text/') + // Sunshine 仅会发 'text/plain;charset=utf-8'。接受任意 text/* + utf-8 + // charset 或未指定 charset 的 text/plain(默认 ASCII⊆UTF-8)。拒绝 + // text/csv;charset=gbk 之类,避免 StringUtil.uint8ArrayToString 按 UTF-8 + // 解码造成乱码。 + if (!mime.startsWith('text/')) { + return false + } + const charsetMatch = mime.toLowerCase().match(/;\s*charset=([^;\s]+)/) + if (!charsetMatch) { + return true + } + const charset = charsetMatch[1].replace(/^"|"$/g, '') + return charset === 'utf-8' || charset === 'utf8' || charset === 'us-ascii' } private isPngMime(mime: string): boolean { diff --git a/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets b/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets index 82e5aa4..12131e2 100644 --- a/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets +++ b/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets @@ -12,8 +12,14 @@ export class SunshineClipboardBlobTransport implements ClipboardBlobTransport { this.nvHttp = nvHttp } - uploadBlob(mime: string, data: Uint8Array): Promise { - return this.nvHttp.uploadClipboardBlob(mime, data) + async uploadBlob(mime: string, data: Uint8Array): Promise { + const result = await this.nvHttp.uploadClipboardBlob(mime, data) + return { + type: 'ref', + id: result.id, + mime: result.mime, + size: result.size + } } downloadBlob(id: string): Promise { diff --git a/entry/src/main/ets/service/streaming/NvHttp.ets b/entry/src/main/ets/service/streaming/NvHttp.ets index 43fa9f2..541efa0 100644 --- a/entry/src/main/ets/service/streaming/NvHttp.ets +++ b/entry/src/main/ets/service/streaming/NvHttp.ets @@ -20,7 +20,6 @@ import { CryptoUtil } from '../../utils/CryptoUtil'; import { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT } from '../../common/NetworkConstants'; import { parseAddressAndPort, formatAddressForUrl } from '../../utils/NetHelper'; import { selectBestAddress, NvHttpHost } from '../../model/ComputerInfo'; -import { ClipboardBlobRef } from '../clipboard/ClipboardSyncConfig'; /** * NvHTTP - 与 NVIDIA GameStream / Sunshine 服务器通信 @@ -85,6 +84,19 @@ interface ClipboardBlobUploadResponse { size?: number | string; } +/** + * Sunshine /api/v1/clipboard/blob 上传响应的扣准结构。 + * 这个类型为 NvHttp 内部使用,定义等价于加了 "id 是必选" 约束的 + * `Required> & { ... }`。调用方 + * (SunshineClipboardBlobTransport)负责包装为业务层的 ClipboardBlobRef, + * 这样 NvHttp 不反向依赖 clipboard 模块。 + */ +export interface ClipboardBlobUploadResult { + id: string; + mime: string; + size: number; +} + /** * getServerInfoRobust 选项 */ @@ -564,10 +576,10 @@ export class NvHttp { } /** - * 上传剪贴板大 payload,返回可放入 KIND_REF 的小 JSON 引用。 - * 与 Sunshine 对接,路径固定为 /api/v1/clipboard/blob。 + * 上传剪贴板大 payload。返回扫描后的 id/mime/size,包装成 KIND_REF 由 + * 调用方负责。与 Sunshine 对接,路径固定为 /api/v1/clipboard/blob。 */ - async uploadClipboardBlob(mime: string, data: Uint8Array): Promise { + async uploadClipboardBlob(mime: string, data: Uint8Array): Promise { const headers: Record = { 'X-Clipboard-Mime': mime } @@ -586,7 +598,7 @@ export class NvHttp { return new Uint8Array(data) } - private parseClipboardBlobUploadResponse(response: string, fallbackMime: string, fallbackSize: number): ClipboardBlobRef { + private parseClipboardBlobUploadResponse(response: string, fallbackMime: string, fallbackSize: number): ClipboardBlobUploadResult { const trimmed = response.trim(); if (!trimmed) { throw new Error('上传剪贴板 blob 返回空响应'); @@ -606,7 +618,6 @@ export class NvHttp { } const size = Number(parsed.size); return { - type: 'ref', id: id, mime: parsed.mime || fallbackMime, size: Number.isFinite(size) && size >= 0 ? Math.floor(size) : fallbackSize From f51ed8971c83303c64f6b235cabd55d844617f14 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 00:34:15 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(clipboard):=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=9B=BE=E7=89=87=E5=90=8C=E6=AD=A5=20+=20?= =?UTF-8?q?=E6=8E=A5=E9=80=9A=20blob=20=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClipboardSyncConfig: 新增 ClipboardBlobRef / KIND_REF=3 / buildRefPayload / parseRefPayload,承载小 JSON 引用包 - ClipboardBridge: ClipboardKind enum 增 REF=3 - HttpClient: 新增 postBinary / postBinaryWithClientCert,支持 headers 自定义; TypedArray body 走 byteOffset/byteLength 切片,避免发送底层完整 buffer - SettingsPageV2: 重新放开图片剪贴板同步开关 - StreamPage: 仅在 image 同步开启时才构造 SunshineClipboardBlobTransport, 并在找不到 computer 时打 warn(不再静默 null 化) --- entry/src/main/ets/pages/SettingsPageV2.ets | 13 ++- entry/src/main/ets/pages/StreamPage.ets | 19 ++-- .../service/clipboard/ClipboardSyncConfig.ets | 48 +++++++++ .../main/ets/service/jni/ClipboardBridge.ets | 3 +- entry/src/main/ets/utils/HttpClient.ets | 102 +++++++++++++++++- 5 files changed, 168 insertions(+), 17 deletions(-) diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index eea8c07..347dcf8 100644 --- a/entry/src/main/ets/pages/SettingsPageV2.ets +++ b/entry/src/main/ets/pages/SettingsPageV2.ets @@ -499,11 +499,7 @@ struct SettingsPageV2 { // 剪贴板同步设置 this.enableClipboardSyncText = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); - const clipboardImageSyncEnabled = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); - if (clipboardImageSyncEnabled) { - this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); - } - this.enableClipboardSyncImage = false; + this.enableClipboardSyncImage = await this.loadBoolean(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); // 输入 - 手柄设置 this.enableVibration = await this.loadBoolean(SettingsKeys.ENABLE_VIBRATION, true); @@ -1811,10 +1807,13 @@ struct SettingsPageV2 { }, { title: '剪贴板同步图片', - subtitle: '暂时禁用 - 图片同步链路仍在排查中', + subtitle: 'Sunshine only - 与主机双向同步 PNG 图片(大图自动走 blob)', type: 'toggle', value: this.enableClipboardSyncImage, - disabled: true + action: () => { + this.enableClipboardSyncImage = !this.enableClipboardSyncImage; + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, this.enableClipboardSyncImage); + } }, { title: '仅控制模式', diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 8864853..efd9fe6 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -52,6 +52,7 @@ import { ToastQueue } from '../utils/ToastQueue'; import { NetworkBoostService, SystemNetScene, SystemNetSceneListener } from '../service/network/NetworkBoostService'; import { ClipboardSyncObserver, ClipboardSyncService } from '../service/clipboard/ClipboardSyncService'; import { ClipboardSyncConfig } from '../service/clipboard/ClipboardSyncConfig'; +import { SunshineClipboardBlobTransport } from '../service/clipboard/SunshineClipboardBlobTransport'; /** MouseEmulationCallback 的具体实现,桥接 MoonBridge 鼠标 API */ class MoonBridgeMouseCallback implements MouseEmulationCallback { @@ -1456,11 +1457,7 @@ struct StreamPage { try { const enableClipboardSyncText = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_TEXT, false); - const enableClipboardSyncImagePref = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); - if (enableClipboardSyncImagePref) { - await PreferencesUtil.put(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); - } - const enableClipboardSyncImage = false; + const enableClipboardSyncImage = await PreferencesUtil.get(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, false); if (!enableClipboardSyncText && !enableClipboardSyncImage) { return; } @@ -1475,9 +1472,19 @@ struct StreamPage { clipboardConfig.enableSyncText = enableClipboardSyncText; clipboardConfig.enableSyncImage = enableClipboardSyncImage; const clipboardObserver: ClipboardSyncObserver = new StreamClipboardObserver(); + let blobTransport: SunshineClipboardBlobTransport | null = null; + if (enableClipboardSyncImage) { + const computer = ComputerManager.getInstance().getComputer(this.computerId); + if (computer) { + blobTransport = new SunshineClipboardBlobTransport(NvHttp.fromComputer(computer, this.context)); + } else { + console.warn(`[StreamPage] 图片剪贴板同步已开启但找不到 computer(id=${this.computerId}),blob 通道不可用`); + } + } this.clipboardSyncService = new ClipboardSyncService( clipboardConfig, - clipboardObserver + clipboardObserver, + blobTransport ); this.clipboardSyncService.start(); } catch (error) { diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets index 222d536..9cb6c2f 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets @@ -3,6 +3,8 @@ * Based on Sunshine clipboard sync protocol (moonlight-common-c PR #5, control packet 0x5508) */ +import { StringUtil } from '../../utils/StringUtil' + /** * Configuration for clipboard synchronization. */ @@ -67,12 +69,20 @@ export interface ClipboardWireParseResult { payload: Uint8Array } +export interface ClipboardBlobRef { + type?: string + id: string + mime: string + size: number +} + export class ClipboardWireFrame { static readonly WIRE_VERSION = 1 static readonly HEADER_SIZE = 10 // version(1) + kind(1) + token(4) + length(4) static readonly KIND_TEXT = 1 static readonly KIND_PNG = 2 + static readonly KIND_REF = 3 /** * Build a wire frame for sending clipboard data. @@ -104,6 +114,44 @@ export class ClipboardWireFrame { return frame } + /** + * Build a small JSON payload for KIND_REF. + */ + static buildRefPayload(ref: ClipboardBlobRef): Uint8Array { + const normalized: ClipboardBlobRef = { + type: 'ref', + id: ref.id, + mime: ref.mime, + size: Math.max(0, Math.floor(ref.size)) + } + return StringUtil.stringToUint8Array(JSON.stringify(normalized)) + } + + /** + * Parse a KIND_REF JSON payload. + */ + static parseRefPayload(payload: Uint8Array): ClipboardBlobRef | null { + try { + const text = StringUtil.uint8ArrayToString(payload) + const parsed = JSON.parse(text) as Partial + const type = typeof parsed.type === 'string' ? parsed.type.trim() : '' + const id = typeof parsed.id === 'string' ? parsed.id.trim() : '' + const mime = typeof parsed.mime === 'string' ? parsed.mime.trim() : '' + const size = Number(parsed.size) + if ((type.length > 0 && type !== 'ref') || !id || !mime || !Number.isFinite(size) || size < 0) { + return null + } + return { + type: type.length > 0 ? type : 'ref', + id: id, + mime: mime, + size: Math.floor(size) + } + } catch (_err) { + return null + } + } + /** * Parse a wire frame received from the host. * @param frame Wire frame bytes diff --git a/entry/src/main/ets/service/jni/ClipboardBridge.ets b/entry/src/main/ets/service/jni/ClipboardBridge.ets index f349c2f..6638950 100644 --- a/entry/src/main/ets/service/jni/ClipboardBridge.ets +++ b/entry/src/main/ets/service/jni/ClipboardBridge.ets @@ -17,7 +17,8 @@ export namespace MoonBridge { */ export enum ClipboardKind { TEXT = 1, - PNG = 2 + PNG = 2, + REF = 3 } /** diff --git a/entry/src/main/ets/utils/HttpClient.ets b/entry/src/main/ets/utils/HttpClient.ets index 8614fae..91723f8 100644 --- a/entry/src/main/ets/utils/HttpClient.ets +++ b/entry/src/main/ets/utils/HttpClient.ets @@ -20,6 +20,8 @@ export interface HttpClientConfig { connectTimeout?: number; /** 传输超时(毫秒) */ transferTimeout?: number; + /** 自定义请求头 */ + headers?: Record; /** 是否跳过服务器证书验证 */ skipServerValidation?: boolean; /** 客户端证书文件路径(PEM 格式) */ @@ -73,6 +75,40 @@ export class HttpClient { }; } + private static buildHeaders(headers?: Record): Record | undefined { + if (!headers) { + return undefined + } + + const entries = Object.entries(headers) + if (entries.length === 0) { + return undefined + } + + const normalized: Record = {} + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + const key = entry[0] + const value = entry[1] + if (!key || value === undefined || value === null) { + continue + } + normalized[key] = String(value) + } + + return Object.keys(normalized).length > 0 ? normalized : undefined + } + + /** + * 将请求体转换为精确的 ArrayBuffer,避免 TypedArray 的 byteOffset/byteLength 干扰。 + */ + private static toExactArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer { + if (data instanceof Uint8Array) { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + } + return data + } + /** * 通用 GET 执行器(文本响应) */ @@ -88,7 +124,7 @@ export class HttpClient { const request = new rcp.Request( url, 'GET', - undefined, + HttpClient.buildHeaders(config?.headers), undefined, undefined, undefined, @@ -208,7 +244,7 @@ export class HttpClient { const request = new rcp.Request( url, 'GET', - undefined, + HttpClient.buildHeaders(config?.headers), undefined, undefined, undefined, @@ -273,7 +309,7 @@ export class HttpClient { const request = new rcp.Request( url, 'POST', - undefined, + HttpClient.buildHeaders(config?.headers), body, undefined, undefined, @@ -298,6 +334,47 @@ export class HttpClient { } } + /** + * 执行 POST 请求(二进制 body)。 + */ + static async postBinary(url: string, body: ArrayBuffer | Uint8Array, config?: HttpClientConfig): Promise { + const connectTimeout = config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT; + const transferTimeout = config?.transferTimeout ?? HttpClient.DEFAULT_TRANSFER_TIMEOUT; + const skipValidation = config?.skipServerValidation !== false; + const key = RcpSessionPool.keyFor(url, config?.clientCertPath); + const session = RcpSessionPool.acquire(key, { skipServerValidation: skipValidation, clientCertPath: config?.clientCertPath, clientKeyPath: config?.clientKeyPath }); + + console.debug(`HttpClient: POST(binary) ${url} (connect=${connectTimeout}ms, transfer=${transferTimeout}ms, sessionKey=${key})`); + + const requestBody = HttpClient.toExactArrayBuffer(body); + const request = new rcp.Request( + url, + 'POST', + HttpClient.buildHeaders(config?.headers), + requestBody, + undefined, + undefined, + HttpClient.buildRequestConfig(connectTimeout, transferTimeout) + ); + + try { + const response = await session.fetch(request); + console.debug(`HttpClient: POST(binary) 响应状态码 = ${response.statusCode}`); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`HTTP ${response.statusCode}`); + } + const responseBody = response.body ? StringUtil.arrayBufferToString(response.body) : ''; + return { statusCode: response.statusCode ?? 0, body: responseBody }; + } catch (err) { + const error = err as Error; + if (HttpClient.isConnectionError(error.message)) { + RcpSessionPool.invalidate(key); + } + console.error(`HttpClient: POST(binary) 请求失败 - ${error.message}`); + throw new Error(`HTTP POST 请求失败: ${error.message}`); + } + } + /** * 执行带客户端证书的 POST 请求 */ @@ -316,4 +393,23 @@ export class HttpClient { transferTimeout: config?.transferTimeout ?? 120000 }); } + + /** + * 执行带客户端证书的二进制 POST 请求。 + */ + static async postBinaryWithClientCert( + url: string, + body: ArrayBuffer | Uint8Array, + certPath: string, + keyPath: string, + config?: HttpClientConfig + ): Promise { + return HttpClient.postBinary(url, body, { + skipServerValidation: true, + clientCertPath: certPath, + clientKeyPath: keyPath, + connectTimeout: config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT, + transferTimeout: config?.transferTimeout ?? 120000 + }); + } } From 6ce0d7bf9c64d111a032aac34d9ea32867b4ee14 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 09:49:27 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(audio):=20=E9=87=8D=E8=BF=9E=E6=97=B6?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=87=8A=E6=94=BE=E6=97=A7=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeArInit 分配 g_decodedAudioBuffer 前先 free 旧 buffer: - 避免上轮 BridgeArCleanup 未完整走完时的内存泄漏 - 防止声道数变化(5.1 ↔ 2.0)时 size 不一致越界 --- entry/src/main/ets/service/AudioVibrationService.ets | 1 + nativelib/src/main/cpp/callbacks.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/entry/src/main/ets/service/AudioVibrationService.ets b/entry/src/main/ets/service/AudioVibrationService.ets index 346c459..18145ef 100644 --- a/entry/src/main/ets/service/AudioVibrationService.ets +++ b/entry/src/main/ets/service/AudioVibrationService.ets @@ -151,6 +151,7 @@ export class AudioVibrationService { */ stop(): void { this.stopAll(); + } // ==================== 场景判断 ==================== diff --git a/nativelib/src/main/cpp/callbacks.cpp b/nativelib/src/main/cpp/callbacks.cpp index 1bb8b3c..c3874ea 100644 --- a/nativelib/src/main/cpp/callbacks.cpp +++ b/nativelib/src/main/cpp/callbacks.cpp @@ -689,7 +689,11 @@ int BridgeArInit(int audioConfiguration, void* opusConfigPtr, void* context, int return -1; } - // 分配解码缓冲区 + // 分配解码缓冲区(先释放旧的,避免由上轮 Cleanup 未完整走完导致的泄漏 / size 不一致越界) + if (g_decodedAudioBuffer) { + free(g_decodedAudioBuffer); + g_decodedAudioBuffer = nullptr; + } g_decodedAudioBuffer = (short*)malloc(opusConfig->channelCount * opusConfig->samplesPerFrame * sizeof(short)); // 初始化音频播放器 From 465122e5564bda5cdba7803effa73de57d1caa87 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 10:01:36 +0800 Subject: [PATCH 09/11] =?UTF-8?q?diag(audio):=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=A2=9E=E5=8A=A0=20PCM=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=BE=BF=E4=BA=8E=E6=8E=92=E6=9F=A5=E7=88=86?= =?UTF-8?q?=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeArDecodeAndPlaySample 增加诊断采样: - 每 ~1s(200 帧)打一行 [AUDIO_DIAG] 窗口统计: frames / samples / maxAbs / meanAbs / 饱和率 / bass 触发数 / intensity 峰值 / decode 失败累计 - 单帧饱和率 > 50% 立即 WARN - decode 返回 <= 0 时打 WARN(前 3 次每次 + 之后每 50 次抽样) - 与 BridgeArInit / Cleanup / Start / Stop 已有 INFO 一起,可按时间轴对位 - 用户复现海生哥反馈的'断连重连卡爆音 + 手柄乱震'后,hilog grep [AUDIO_DIAG] 即可定位是否爆音、爆音特征(饱和/解码失败/intensity 突高) --- nativelib/src/main/cpp/callbacks.cpp | 73 +++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/nativelib/src/main/cpp/callbacks.cpp b/nativelib/src/main/cpp/callbacks.cpp index c3874ea..bcda854 100644 --- a/nativelib/src/main/cpp/callbacks.cpp +++ b/nativelib/src/main/cpp/callbacks.cpp @@ -832,18 +832,68 @@ void BridgeArDecodeAndPlaySample(char* sampleData, int sampleLength) { g_decodedAudioBuffer, g_opusConfig.samplesPerFrame ); - + + // ---- 诊断采样:每 ~1s 输出一行 PCM 统计,异常帧立即 WARN ---- + // 用于排查"断连后重连卡爆音 + 手柄乱震"类问题 + // sequence 关联:BridgeArInit/Cleanup 已有 INFO 日志,便于按时间轴对位 + static thread_local uint64_t s_arvFrameSeq = 0; + static thread_local uint64_t s_arvDecodeFailCount = 0; + static thread_local int64_t s_arvSatSum = 0; // saturated sample 累计 + static thread_local int64_t s_arvAbsSum = 0; + static thread_local int s_arvMaxAbs = 0; + static thread_local int s_arvSampleCount = 0; + static thread_local int s_arvBassFires = 0; + static thread_local int s_arvLastIntensityMax = 0; + constexpr int kSatThreshold = 30000; + constexpr int kFramesPerLog = 200; // ~1s @5ms/frame + + s_arvFrameSeq++; + if (decodeLen <= 0) { + s_arvDecodeFailCount++; + if (s_arvDecodeFailCount <= 3 || (s_arvDecodeFailCount % 50) == 0) { + OH_LOG_WARN(LOG_APP, "[AUDIO_DIAG] decode failed/empty: seq=%{public}llu len=%{public}d failCount=%{public}llu", + (unsigned long long)s_arvFrameSeq, decodeLen, (unsigned long long)s_arvDecodeFailCount); + } + } + if (decodeLen > 0) { // 始终写入解码后的音频,不在解码层丢帧 // 延迟控制由环形缓冲区内部处理:满时丢弃旧数据、写入新数据 // 这样波形始终连续,避免丢帧导致的电流滋啦声 AudioRendererInstance::PlaySamples(g_decodedAudioBuffer, decodeLen); - + + // 累计本帧 PCM 统计 + const int totalSamples = decodeLen * g_opusConfig.channelCount; + int frameMaxAbs = 0; + int64_t frameAbsSum = 0; + int frameSat = 0; + for (int i = 0; i < totalSamples; i++) { + int v = g_decodedAudioBuffer[i]; + int a = v < 0 ? -v : v; + if (a > frameMaxAbs) frameMaxAbs = a; + frameAbsSum += a; + if (a >= kSatThreshold) frameSat++; + } + s_arvSatSum += frameSat; + s_arvAbsSum += frameAbsSum; + s_arvSampleCount += totalSamples; + if (frameMaxAbs > s_arvMaxAbs) s_arvMaxAbs = frameMaxAbs; + + // 单帧异常告警:饱和率 > 50% + if (totalSamples > 0 && frameSat * 2 > totalSamples) { + OH_LOG_WARN(LOG_APP, "[AUDIO_DIAG] frame saturation: seq=%{public}llu satRatio=%{public}d%% maxAbs=%{public}d totalSamples=%{public}d", + (unsigned long long)s_arvFrameSeq, + (int)(frameSat * 100 / totalSamples), + frameMaxAbs, totalSamples); + } + // 低频能量分析(音频振动) int bassIntensity = 0; int bassLowFreqRatio = 50; int bassStereoBalance = 50; if (g_bassAnalyzer.ProcessFrame(g_decodedAudioBuffer, decodeLen, bassIntensity, bassLowFreqRatio, bassStereoBalance)) { + s_arvBassFires++; + if (bassIntensity > s_arvLastIntensityMax) s_arvLastIntensityMax = bassIntensity; if (g_audioCallbacks.tsfn_bassEnergy) { CallbackData* data = new CallbackData(); data->intParams[0] = bassIntensity; @@ -854,6 +904,25 @@ void BridgeArDecodeAndPlaySample(char* sampleData, int sampleLength) { } } } + + if ((s_arvFrameSeq % kFramesPerLog) == 0 && s_arvSampleCount > 0) { + const int satPct = (int)(s_arvSatSum * 100 / s_arvSampleCount); + const int meanAbs = (int)(s_arvAbsSum / s_arvSampleCount); + OH_LOG_INFO(LOG_APP, + "[AUDIO_DIAG] window seq=%{public}llu frames=%{public}d samples=%{public}d " + "maxAbs=%{public}d meanAbs=%{public}d satPct=%{public}d%% bassFires=%{public}d intensityMax=%{public}d decodeFails=%{public}llu", + (unsigned long long)s_arvFrameSeq, kFramesPerLog, + s_arvSampleCount, s_arvMaxAbs, meanAbs, satPct, + s_arvBassFires, s_arvLastIntensityMax, + (unsigned long long)s_arvDecodeFailCount); + s_arvSatSum = 0; + s_arvAbsSum = 0; + s_arvSampleCount = 0; + s_arvMaxAbs = 0; + s_arvBassFires = 0; + s_arvLastIntensityMax = 0; + // decodeFails 不清零,便于看累计 + } } // 连接监听器回调 From 03018ca87fc744faed669564774b5ed3cdb58e8d Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 10:27:30 +0800 Subject: [PATCH 10/11] fix(native): bump moonlight-common-c with NULL guards for FEC paths Two upstream NULL deref crashes observed in AGC reports (100+ records): - RtpvAddPacket+0xC00: pendingFecBlockList.head NULL in reconstructFrame cleanup_packets path -> SIGSEGV @ 0x10 on VideoRecv thread. - RtpaGetQueuedPacket+0x138: dataPackets[i] NULL -> memcpy SIGSEGV @ 0 on AudioRecv thread. Submodule pointer: 3dde0f1 -> 2bceac6 (mic branch). --- nativelib/src/main/cpp/moonlight-common-c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nativelib/src/main/cpp/moonlight-common-c b/nativelib/src/main/cpp/moonlight-common-c index 3dde0f1..07a763b 160000 --- a/nativelib/src/main/cpp/moonlight-common-c +++ b/nativelib/src/main/cpp/moonlight-common-c @@ -1 +1 @@ -Subproject commit 3dde0f103aeaa5c7396a676159887545b77414e5 +Subproject commit 07a763bfacbc964f2f049dceb1fb39cef19420b5 From de41ca3f2ddbe24f9d0a7afb22db1b4cc950dc51 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 12 May 2026 10:47:51 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix(clipboard):=20blob=20=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E7=A1=AC=E6=80=A7=E6=A0=A1=E9=AA=8C=20+=20cert=20?= =?UTF-8?q?=E5=8C=85=E8=A3=85=E6=96=B9=E6=B3=95=E9=80=8F=E4=BC=A0=20header?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review 反馈: - ClipboardSyncService.downloadClipboardBlob 仅在 size 不匹配时记日志便继续解码, 恶意/异常服务端可借此触发客户端 OOM。改为在下载前/后做硬性 64 MiB 上限校验, 并把 size mismatch 升级为 hard fail。 - HttpClient.postWithClientCert / postBinaryWithClientCert 构造 inline config 时 漏传 config?.headers,会导致上层(如 X-Clipboard-Mime)传入的请求头被静默 丢弃。 --- .../service/clipboard/ClipboardSyncConfig.ets | 8 ++++++++ .../clipboard/ClipboardSyncService.ets | 20 ++++++++++++++++++- entry/src/main/ets/utils/HttpClient.ets | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets index 9cb6c2f..75905a0 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets @@ -76,6 +76,14 @@ export interface ClipboardBlobRef { size: number } +/** + * Hard upper bound for any single clipboard blob (download or upload). + * 64 MiB comfortably covers a full-screen 4K PNG screenshot while preventing + * a malicious or buggy host from exhausting client memory by advertising or + * actually returning an unbounded payload. + */ +export const CLIPBOARD_BLOB_MAX_BYTES: number = 64 * 1024 * 1024 + export class ClipboardWireFrame { static readonly WIRE_VERSION = 1 static readonly HEADER_SIZE = 10 // version(1) + kind(1) + token(4) + length(4) diff --git a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets index 4ca94a7..6b5d078 100644 --- a/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -8,7 +8,8 @@ import { ClipboardWireFrame, ClipboardBlobRef, TokenEntry, - ClipboardSyncListener + ClipboardSyncListener, + CLIPBOARD_BLOB_MAX_BYTES } from './ClipboardSyncConfig' const TAG = 'ClipboardSyncService' @@ -402,10 +403,27 @@ export class ClipboardSyncService implements ClipboardSyncListener { return null } + // Reject before download if the host advertises an oversized blob. + if (ref.size > CLIPBOARD_BLOB_MAX_BYTES) { + logWarn(`Clipboard blob too large id=${ref.id} advertised=${ref.size} max=${CLIPBOARD_BLOB_MAX_BYTES}`) + this.observer?.onError?.(`远端剪贴板内容过大(${ref.size} 字节,上限 ${CLIPBOARD_BLOB_MAX_BYTES})`) + return null + } + try { const data = await this.blobTransport.downloadBlob(ref.id) + // Independent post-download cap: even when ref.size==0 (unknown), refuse + // to hand a multi-hundred-MiB buffer to the PNG/text decoder. + if (data.length > CLIPBOARD_BLOB_MAX_BYTES) { + logWarn(`Clipboard blob payload exceeds cap id=${ref.id} actual=${data.length} max=${CLIPBOARD_BLOB_MAX_BYTES}`) + this.observer?.onError?.(`远端剪贴板内容超出大小上限(${data.length} 字节)`) + return null + } + // Hard fail on size mismatch: an honest server must report accurate size. if (ref.size > 0 && data.length !== ref.size) { logWarn(`Clipboard blob size mismatch id=${ref.id} expected=${ref.size} actual=${data.length}`) + this.observer?.onError?.(`远端剪贴板内容大小不一致(声明 ${ref.size},实际 ${data.length})`) + return null } return data } catch (err) { diff --git a/entry/src/main/ets/utils/HttpClient.ets b/entry/src/main/ets/utils/HttpClient.ets index 91723f8..f3ac31e 100644 --- a/entry/src/main/ets/utils/HttpClient.ets +++ b/entry/src/main/ets/utils/HttpClient.ets @@ -389,6 +389,7 @@ export class HttpClient { skipServerValidation: true, clientCertPath: certPath, clientKeyPath: keyPath, + headers: config?.headers, connectTimeout: config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT, transferTimeout: config?.transferTimeout ?? 120000 }); @@ -408,6 +409,7 @@ export class HttpClient { skipServerValidation: true, clientCertPath: certPath, clientKeyPath: keyPath, + headers: config?.headers, connectTimeout: config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT, transferTimeout: config?.transferTimeout ?? 120000 });