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..e7531d0 --- /dev/null +++ b/docs/CLIPBOARD_SYNC_INTEGRATION_HARMONYOS.md @@ -0,0 +1,196 @@ +# 鸿蒙剪贴板同步集成指南 + +> 本文档描述的是当前仓库中**已经实现并通过构建验证**的 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 +- 本地或远端一旦进入图片分支,只提示一次不支持 + - 当前 `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 + +这样做的好处是: + +- 构建稳定 +- 行为明确 +- 未来恢复图片同步时,不需要重新设计协议和 UI + +## 8. 建议的人工验证步骤 + +### 基础检查 + +- [ ] 设置页能看到文本/图片同步两个开关 +- [ ] 启动串流后日志显示 clipboard sync start +- [ ] 退出串流后服务停止 + +### 文本同步 + +- [ ] 本地复制文本 → 主机端有反应 +- [ ] 主机复制文本 → 设备本地系统剪贴板更新 +- [ ] 关闭文本同步后不再收发文本 + +### 图片同步降级 + +- [ ] 打开图片同步后,仅出现一次降级提示 +- [ ] 收到主机 PNG 时不会崩溃、不会写入错误数据 + +## 9. 当前已知限制 + +1. 仅 Sunshine 支持该协议扩展 +2. 兼容 SDK 下图片剪贴板 API 仍待后续验证 +3. 大 payload 仍受单帧大小限制 +4. 本轮验证以编译通过和代码闭环为主,不含真机端到端专项回归 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(); diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index cb0282b..347dcf8 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 = '自动'; // 自动/仅手柄/仅设备/同时 @@ -493,6 +497,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, '自动'); @@ -1787,6 +1795,26 @@ 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: 'Sunshine only - 与主机双向同步 PNG 图片(大图自动走 blob)', + type: 'toggle', + value: this.enableClipboardSyncImage, + action: () => { + this.enableClipboardSyncImage = !this.enableClipboardSyncImage; + this.saveSetting(SettingsKeys.ENABLE_CLIPBOARD_SYNC_IMAGE, this.enableClipboardSyncImage); + } + }, { title: '仅控制模式', subtitle: '禁用音视频,仅传输输入', diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 67e7a07..efd9fe6 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'; @@ -50,6 +50,9 @@ 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'; +import { SunshineClipboardBlobTransport } from '../service/clipboard/SunshineClipboardBlobTransport'; /** MouseEmulationCallback 的具体实现,桥接 MoonBridge 鼠标 API */ class MoonBridgeMouseCallback implements MouseEmulationCallback { @@ -70,6 +73,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 +197,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 +674,8 @@ struct StreamPage { aboutToDisappear(): void { console.info('StreamPage aboutToDisappear - cleaning up'); + this.stopClipboardSync(); + // 取消 Network Boost 场景监听 if (this.netSceneListener) { NetworkBoostService.getInstance().removeSceneListener(this.netSceneListener); @@ -1286,6 +1298,7 @@ struct StreamPage { // 设置连接终止回调 this.viewModel.setConnectionTerminatedCallback((errorCode: number) => { + this.stopClipboardSync(); this.lifecycleManager.handleConnectionTerminated(errorCode); }); @@ -1298,6 +1311,8 @@ struct StreamPage { await this.launchStream(); this.streamStartedAtMs = Date.now(); + await this.startClipboardSync(); + // 超分辨率状态 Toast this.showUpscaleToast(); @@ -1402,6 +1417,82 @@ 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(); + 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, + blobTransport + ); + this.clipboardSyncService.start(); + } catch (error) { + console.error('Failed to initialize clipboard sync:', error); + this.stopClipboardSync(); + } + } + /** * 战报展示期间,真正的停流/退游戏在后台继续执行;关闭战报前再等待其收尾。 */ @@ -1476,6 +1567,8 @@ struct StreamPage { return; } + this.stopClipboardSync(); + if (!this.endStreamReportEnabled) { await this.prepareStreamEndUi(); await this.exitStreamAndReturn(); @@ -1504,6 +1597,8 @@ struct StreamPage { return; } + this.stopClipboardSync(); + const report = this.endStreamReportEnabled ? this.buildCurrentStreamReport() : null; this.prepareStreamEndUiImmediate(); @@ -1535,6 +1630,8 @@ struct StreamPage { async reconnectStreaming(): Promise { console.info('[StreamPage] 开始重连串流...'); try { + this.stopClipboardSync(); + // 先停止当前会话(静默,不导航不显示 toast) await this.viewModel.stopStreaming(); console.info('[StreamPage] 旧会话已停止,开始重新连接...'); @@ -1545,6 +1642,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/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/entry/src/main/ets/service/SettingsService.ets b/entry/src/main/ets/service/SettingsService.ets index 69b7f54..926c7e4 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 = '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'; static readonly VIBRATION_MODE: string = 'settings_vibration_mode'; // 震动模式:自动/仅手柄/仅设备/同时 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/ClipboardSyncConfig.ets b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets new file mode 100644 index 0000000..75905a0 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncConfig.ets @@ -0,0 +1,217 @@ +/** + * Clipboard sync configuration and data structures. + * Based on Sunshine clipboard sync protocol (moonlight-common-c PR #5, control packet 0x5508) + */ + +import { StringUtil } from '../../utils/StringUtil' + +/** + * 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 interface ClipboardBlobRef { + type?: string + id: string + mime: string + 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) + + static readonly KIND_TEXT = 1 + static readonly KIND_PNG = 2 + static readonly KIND_REF = 3 + + /** + * 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 + } + + /** + * 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 + * @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..6b5d078 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/ClipboardSyncService.ets @@ -0,0 +1,535 @@ +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, + CLIPBOARD_BLOB_MAX_BYTES +} 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 PasteDataRecordAccessor { + mimeType?: string + plainText?: string + pixelMap?: image.PixelMap + toPlainText?(): string +} + +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. + */ +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, blobTransport?: ClipboardBlobTransport | null) { + this.config = config ? config : new ClipboardSyncConfig() + this.observer = observer ? observer : null + this.blobTransport = blobTransport ? blobTransport : null + } + + start(): void { + if (this.isRunning) { + return + } + + if (!this.config.enableSyncText && !this.config.enableSyncImage) { + logInfo('Clipboard sync disabled in config') + return + } + + 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() + if (!this.pasteboardEventListener) { + this.pasteboardEventListener = (): void => { + void this.onPasteboardChanged() + } + } else { + sysBoard.off('update', this.pasteboardEventListener) + } + sysBoard.on('update', this.pasteboardEventListener) + } 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 && !this.config.enableSyncImage) { + return + } + + try { + const sysBoard = pasteboard.getSystemPasteboard() + const pasteData = await sysBoard.getData() + const recordCount = pasteData.getRecordCount() + const text = this.config.enableSyncText ? this.tryGetPrimaryText(pasteData, recordCount) : '' + + if (this.config.enableSyncText && text && text.length > 0) { + logInfo(`Local clipboard changed: recordCount=${recordCount}, textLength=${text.length}`) + await this.sendTextPayload(text) + return + } + + if (this.config.enableSyncImage) { + const primaryPixelMap = this.tryGetPrimaryPixelMap(pasteData, recordCount) + if (primaryPixelMap) { + logInfo(`Local clipboard changed: recordCount=${recordCount}, detected pixelMap image`) + await this.sendImagePayload(primaryPixelMap) + 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}`) + this.observer?.onError?.(`读取本地剪贴板失败:${message}`) + } + } + + 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 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 + } + + 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 + } + + 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}`) + this.observer?.onError?.(`读取本地剪贴板图片失败:${message}`) + } finally { + if (packer) { + packer.release() + } + } + } + + 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 { + // 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 { + 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`) + 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_REF) { + await this.applyRemoteBlobReference(payload) + return + } + + if (kind === ClipboardWireFrame.KIND_PNG) { + if (!this.config.enableSyncImage) { + return + } + await this.writeRemoteImagePayload(payload, '直接帧') + return + } + + if (kind !== ClipboardWireFrame.KIND_TEXT || !this.config.enableSyncText) { + return + } + + await this.writeRemoteTextPayload(payload, '直接帧') + } + + 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.writeRemoteTextPayload(blobData, `blob id=${ref.id}`) + return + } + + if (this.isPngMime(ref.mime)) { + await this.writeRemoteImagePayload(blobData, `blob id=${ref.id}`) + 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 + } + + // 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) { + const message = formatError(err as Object) + logError(`Failed to download clipboard blob id=${ref.id}: ${message}`) + this.observer?.onError?.(`下载远端剪贴板内容失败:${message}`) + return null + } + } + + /** + * 解码并写入远端文本 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 (claimedSelfWrite && this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } + const message = formatError(err as Object) + logError(`Failed to set local clipboard text (${source}): ${message}`) + this.observer?.onError?.(`写入本地剪贴板失败:${message}`) + } + } + + /** + * 解码并写入远端图片 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 (claimedSelfWrite && this.pendingSelfWrites > 0) { + this.pendingSelfWrites-- + } + const message = formatError(err as Object) + logError(`Failed to set local clipboard image (${source}): ${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 { + // 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 { + return mime === CLIPBOARD_PNG_MIME + } + + 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 + } +} 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..12131e2 --- /dev/null +++ b/entry/src/main/ets/service/clipboard/SunshineClipboardBlobTransport.ets @@ -0,0 +1,28 @@ +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 + } + + 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 { + return this.nvHttp.downloadClipboardBlob(id) + } +} 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..6638950 --- /dev/null +++ b/entry/src/main/ets/service/jni/ClipboardBridge.ets @@ -0,0 +1,115 @@ +/** + * 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 nativeLib from 'libmoonlight_nativelib.so' +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, + REF = 3 + } + + /** + * 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 exported by libmoonlight_nativelib.so + return nativeLib.sendClipboardData(frame) as number + } + + /** + * 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 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/NvHttp.ets b/entry/src/main/ets/service/streaming/NvHttp.ets index ec2af68..541efa0 100644 --- a/entry/src/main/ets/service/streaming/NvHttp.ets +++ b/entry/src/main/ets/service/streaming/NvHttp.ets @@ -78,6 +78,25 @@ interface RotateDisplayResponse { success?: boolean; } +interface ClipboardBlobUploadResponse { + id?: string; + mime?: string; + 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 选项 */ @@ -115,6 +134,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 +459,171 @@ 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。返回扫描后的 id/mime/size,包装成 KIND_REF 由 + * 调用方负责。与 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): ClipboardBlobUploadResult { + 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 { + id: id, + mime: parsed.mime || fallbackMime, + size: Number.isFinite(size) && size >= 0 ? Math.floor(size) : fallbackSize + }; + } + /** * 获取服务器信息 * 优先使用 HTTPS(如果已配对),失败后尝试 HTTP 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/ets/utils/HttpClient.ets b/entry/src/main/ets/utils/HttpClient.ets index 8614fae..f3ac31e 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 请求 */ @@ -312,6 +389,27 @@ export class HttpClient { skipServerValidation: true, clientCertPath: certPath, clientKeyPath: keyPath, + headers: config?.headers, + connectTimeout: config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT, + 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, + headers: config?.headers, connectTimeout: config?.connectTimeout ?? HttpClient.DEFAULT_CONNECT_TIMEOUT, transferTimeout: config?.transferTimeout ?? 120000 }); 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..bcda854 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)); @@ -658,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)); // 初始化音频播放器 @@ -797,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; @@ -819,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 不清零,便于看累计 + } } // 连接监听器回调 @@ -971,6 +1075,73 @@ 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; + } + + 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 - kClipboardWireHeaderSize; + + 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; + } + + 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); + 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 + kClipboardWireHeaderSize, 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..07a763b 160000 --- a/nativelib/src/main/cpp/moonlight-common-c +++ b/nativelib/src/main/cpp/moonlight-common-c @@ -1 +1 @@ -Subproject commit 80f240852d7bed78161cd2862226119b91a3f7bb +Subproject commit 07a763bfacbc964f2f049dceb1fb39cef19420b5 diff --git a/nativelib/src/main/cpp/moonlight_bridge.cpp b/nativelib/src/main/cpp/moonlight_bridge.cpp index 749055c..ea78d83 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,63 @@ 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); + if (status == napi_ok && isTypedArray) { + napi_typedarray_type type; + napi_value arrayBuffer; + size_t byteOffset = 0; + 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; + 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 +856,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 +874,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 },