DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

zengxiaolou /

zengxiaolou/dsh-device-mirror

Verified

This plugin has no description yet.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@991ae35f

Device Mirror(@local/dsh-device-mirror)

dsh-device-mirror

npm downloads license dsh plugin stars

把 iOS 模拟器 / 安卓模拟机的实时画面投进 DSH,并能直接在上面点、拖、打字。

只有一种形态:浮窗,而且可以开多个。

项 说明
落位 shell.overlay slot(帧级浮层),一个 slot 承载 N 个窗口
窗口 每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭
新增 在窗口里的设备管理器创建,没有常驻的全局 + 按钮
依赖 无

刻意不做右栏 Tab:正在操作一台设备时要的是浮在工作区之上,不是挤进和文件树、diff 共享宽度的窄栏里。

多个窗口共用一份采集:Host 的生产者按 platform:device 建、带订阅者计数,所以同一个设备开两个窗口只采集一次,两个设备开两个窗口各采一份。

窗口跟着会话走,不是全局的

项 说明
归属 窗口集合属于当前会话,切会话就换一套
存储 localStorage 键按会话分:dsh-device-mirror:panels:<sessionId>
切走时 先把当前窗口集合写回自己的键,再载入目标会话的集合
首次进入某会话 自动开一个窗口;下次回到这个会话,设备与位置都还原
采集 切会话会让旧窗口卸载,其流随之断开;最后一个订阅者离开 15 s 后停采集

会话 id 取自 Client 的 ctx.sessions.list 快照(current,或 retainedBy.mainView 持有者)。 服务若始终不出现,会退回到全局键 dsh-device-mirror:panels 并照常工作——这是降级,不是正常路径。

交互:标题栏常驻,按钮悬停才出

每个窗口上方有一条常驻的细标题栏(设备名 · 状态点),它同时是三样东西:

动作 结果
按住标题栏拖 移动窗口(整条都是把手,不是某个小点)
单击标题栏(不移动) 打开该窗口的设备管理器
标题栏右侧 ⌨ ⟳ — ×(键盘 / 重连 / 收起成胶囊 / 关闭),常驻
导航行(标题栏下方) ‹ ⌂ ▢ 返回 / 主屏 / 最近任务,常驻,不藏在键盘开关后面

拖拽把手做成"整条标题栏"是有原因的:上一版把手是悬停才出现的窄条,鼠标从画面移到条上时容易丢失,很难抓。 现在标题栏既不会遮住画面(在画面外侧),也不会消失,整条都是把手。

— 会把窗口收成一枚小胶囊(只显示设备名),点胶囊就地还原;隐藏状态随会话保存,刷新后仍是胶囊。

界面约定:控件不压在画面上

每个窗口都遵守两条硬规则,都是为了镜像能用,而不是看起来简洁:

1. 静止时只有画面。 面板本体的静止状态就是一块设备屏幕,没有常驻工具栏、指标条或坐标信息。

2. 控件永远在画面之外。 所有按钮收进一条吸附在画面顶部外侧的工具条,鼠标移上去才淡入(离开 220ms 后淡出)。 它不覆盖画面、不占据画面的点击区域,所以你在设备上点顶端/底端任何一个像素都不会落到按钮上。

每个窗口的工具条(从左到右)
设备名 ▾ 点击打开选择器
状态点 绿=实时 / 橙=连接中 / 红=出错 / 灰=空闲
◀ ⌂ ▣ BACK / HOME / APP_SWITCH
⌨ 切换文字输入行(第二行,仍在画面之外)
⟳ × 重连 / 关闭这个窗口

工具条是暗色 .82 不透明而不是浅色半透明:设备屏幕经常是白底,浅色玻璃会让图标全部消失。

拖动由每个窗口的常驻标题栏承担(不是画面):画面上没有任何隐形热区。位置写回 localStorage,刷新后复原。

选择器替换画面而不是叠在它上面——面板尺寸不变,底边锚定的浮窗不会被顶出视口。

唯一的例外是非 live 状态:连不上或还没选设备时,屏幕中央显示状态卡 + 重试按钮。这张卡属于画面内容,不受 hover 控制(否则打开面板会是一片空白,没有任何线索)。


生命周期:什么时候起、什么时候停

设备不是常驻进程,面板不是常驻窗口。约定如下:

时点 行为
会话里还没有窗口 右下角出现一枚虚线胶囊「设备镜像 +」,点它在本会话开一个窗口
窗口里加设备 单击标题栏 → 设备管理器 → 选一台运行中的 / 启动一台关机的 / 新建一台模拟器
新建模拟器 · iOS 选模板(124 种)+ 系统版本 → simctl create → 自动绑定并启动(实测冷启动 6.9 s)
新建模拟器 · Android 选系统镜像 + 机型 → avdmanager create avd → 启动 emulator -avd → 等到 sys.boot_completed 再把 AVD 名换成 adb 序列号绑定(实测首启 50–68 s)
再开一个窗口 设备管理器底部的「再开一窗」,新窗口仍属于当前会话
窗口换设备 每个窗口各选各的,互不影响;同一设备开两个窗口共用一份采集
拖窗口 工具条拖动,位置写回 localStorage,刷新后复原,并被夹在视口内
设备没开机 状态卡直接给 「启动并连接」,一键 boot + 自动重连(靠设备状态变化触发,不用再点重连)
关窗口 只销毁这个窗口;最后一个订阅者离开 15 s 后才停采集,并收掉设备上的 screenrecord
切换会话 窗口集合整体换掉;旧窗口卸载、流断开,设备本身不动
关掉插件 / reload SIGKILL 自己起的 adb / ffmpeg / simctl,不留孤儿

采集分辨率:跟面板尺寸和 DPR 走

帧是 1:1 画进面板的,所以采集宽度必须匹配"面板 CSS 宽 × 像素比":

视口高 DPR 面板 CSS 宽 iOS 采集宽 Android 采集宽
900 2 344 900(1.3× 超采样) 720(编码器上限)
900 1 344 440 440
760 2 281 760 720

三个独立的质量决定,都是量出来的:

决定 依据
缩放下采样用 lanczos 同尺寸下 Laplacian 锐度 920 → 989(+7.5%),体积只多 1.4%
采集 1.3× 超采样 720 → 900,最终显示尺寸下锐度 989 → 1123(+13.6%);到 1000 就不再涨,故 1.3× 是拐点
质量 q10(原 q13) +1.2% 锐度、+14% 体积,比色度 4:4:4 划算(444 只多 0.1% 锐度却多 30% 体积)

Android 采集封顶 720,且输出不超过采集宽。 这里踩过一个坑:把输出提到 900 后,模拟器编码器在 --size 900x2000 下 3 秒只出 2 帧(720x1600 有 19 帧),画面直接冻住。所以 Android 采集 720、 输出也用 720(2x 屏要 688,正好 1:1),绝不放大。

宽度按 40px 步长量化,避免布局动一像素就把生产者拆了重建。

导航键:两端能力不一样,不能假装一样

键 Android iOS 模拟器
返回 ‹ input keyevent KEYCODE_BACK 读无障碍树找导航栏返回按钮(label 是上一页标题,固定在左上 16,62 44x44),点它的中心;找不到才退化为左边缘右滑
主屏 ⌂ KEYCODE_HOME idb ui button HOME(硬件键,实测卡在过渡动画时也能用)
最近任务 ▢ KEYCODE_APP_SWITCH 不支持,按钮置灰并说明原因

实测:iOS 返回 36.1% 画面变化(确实回到上一级)、主屏 91.4%(回到桌面); APP_SWITCH 会得到 400 {ok:false, unsupported:true, error:'the iOS Simulator has no app-switcher API; use Android for that key'}。

另外两条实测过的死路,记下来免得再试:idb ui key <code> 退出码 0 但什么都不做; 左边缘返回手势在模拟器卡在过渡动画时无效(而硬件 HOME 仍然有效,能把它救回来)。

操作手感:延迟和反馈

"不流畅"通常不是帧率,而是按下去没有任何回应。两端各自的真实延迟:

平台 一次点击的端到端耗时 里面是什么
Android 90–120 ms adb shell input tap 本身 ~100 ms(几何信息已缓存,不再每次探测)
iOS 270 ms idb ui tap --duration 0.1,其中 ~250 ms 是 python 启动,砍不掉

超时(机器被别的程序压满时)会明确回 504 <kind> timed out — the device or this machine is too busy to answer, 并在画面上方短暂显示一条红色提示——失败不会静默。

修复前的 Android 是 190–230 ms:POST /input 每次都重新跑 adb devices -l + wm size + AVD 探测。 现在几何信息按 platform:device 缓存 60 s,任何 boot/create/shutdown 都会让它失效。iOS 也从 620 ms 降到 270 ms。

但 100–270 ms 的往返仍然可感知,所以界面上做了本地手势回显: 按下立刻画出触点,拖动时画出虚线轨迹,抬手后触点放大淡出 240 ms。 设备那帧要晚到才更新,但操作不会显得没反应。

实时拖动做不到。 adb shell input motionevent DOWN/MOVE/UP 退出码 0 却不产生任何手势 (实测截图零变化,带 touchscreen source 也一样),所以滑动仍然是抬手时整段发出。 scrcpy-server 那条路能做到实时手势,但需要额外引入一个二进制。

Android 实时手势:模拟器 gRPC

adb shell input 每次是一个一次性进程,注入完就没有了,所以拖动只能"抬手后整段重放"。 模拟器另外提供 EmulatorController/sendTouch,接受触摸流,可以让拖动跟着指针走。

实现没有引入任何依赖:gRPC 就是 HTTP/2 + 5 字节长度前缀,而这两个消息各只有三个 varint, 所以线格式和传输都是手写的(host/emulator-grpc.js,Node 内置 http2)。

环节 做法
启动 自己启动模拟器时挑一个空闲端口并加 -grpc <port>,启动成功后登记 serial→port
接管 用户手动启动的模拟器:在 8554–8559 探测已在监听的桥,且必须只有一个设备连接(桥不带 serial,两个设备时接管会驱动错机器)
手势 按下发 down、移动按 33ms 节流发 move、抬手发 up;首次被拒则该窗口永久回落到整段滑动
失败 没有桥时回退到原来的 input swipe,行为与之前一致

实测:路由级 down→8×move→up,像素变化 17.7–95.8%,全部返回 realtime: true; 重载插件后自动接管已在运行的模拟器(诊断里会出现 android:grpc bridge adopted on port 8554)。

为什么 iOS 不用 idb 的 h264 流(试过,回退了)

idb video-stream --format h264 --udid <udid> 是可用的——这一点是对的,我实测 8 秒拿到 167 帧(1206×2622,≈21 fps), 开头 00 00 00 01 27 是标准 Annex-B 的 SPS。之前判"不可用"是选错了参数:--format mjpeg 确实挂 (grpclib StreamTerminatedError),simctl io recordVideo - 到 stdout 被 Apple 删除了。

但它救不了流畅度,因为帧是成批到达的。 直接测 idb video-stream 的字节到达节奏:

指标 实测
6 秒内数据块 65 个
间隔 > 80ms 的停顿 22 次
最长停顿 696ms(把模拟器窗口切到前台再测:24 次 / 783ms,不是窗口降频)

累计帧数确实高(约 15–21 fps),但它们挤在每 250–700ms 一波的簇里。而交付层是"每 minGap 只写最新一帧", 于是一簇只出一帧 → 交付 1.8–2.0 fps,比截图路线的 8.3 fps 更差。

试过 -f image2pipe + -flush_packets 1(成批发生在 idb 传输层,不在 ffmpeg 输出层,无效)。 试过把整簇都发出去,但那一簇里的帧代表过去 500ms 的不同瞬间,发出去就是"抖一下再冻住"。

结论:h264 路线的价值是"帧更新鲜",不是"更平滑"。若要用,得先解决 idb 的成批推送 (或换一条真正逐包交付的通道)。当前保留截图路线,代码已回退。

交付节流:跟着帧到达走,而不是定时采样

原来用 setInterval 每 100ms 查一次"有新帧吗"。实测生产者 15.4 fps,交付只有 5.2 fps—— 因为 iOS 的 3 个采集工人几乎同时抓屏,帧成批到达,大半 tick 落在空档里。

改成尾沿节流:每来一帧就安排在 上次写 + 最小间隔 写出,不漏到达;同时把 3 个工人的启动 错开 60ms,让帧流变匀。实测 5.2 → 7.8 fps(上限 10)。

改这个时发现一个更深的问题:Producer.publish 根本不通知 subscribers——它一直是纯引用计数, 所以挂上去的回调只在订阅那一刻被调用一次。修好后到达驱动的写入才真正成立。

Android 的天花板是 screenrecord 本身。 持续拖拽期间 screenrecord 只有 ~6 fps (720x1600、6 秒 34 帧),交付 ~3 fps。试过让 ffmpeg fps=10 重采样摊平突发,结果更差 (34 帧 → 14 帧,因为原始时间戳不规则),已放弃。

交互流畅度的取舍

决定 为什么
拖动只写 transform,不走 React state 早前每次 pointermove 都 setState,整棵树(含 blob 图)每像素重渲染一次,拖起来发黏
采集宽按需计算(面板 CSS 宽 × DPR,步长 80,封顶 900) 原来写死 380,而 2x 屏上 344 CSS 宽的面板要 688 设备像素——放大 1.8 倍必然糊;现在默认取 720,1:1 显示
窗口宽度按视口高度收缩 20:9 手机在 344px 宽时约 760px 高,短窗口会把底部裁掉;现在宽度让步、比例不变
设备管理器内容自适应高度 早前被撑成手机比例,两行设备留下巨大空白、底部按钮贴在最边缘像被裁了

iOS 的帧率有天花板。 它是 simctl io screenshot 轮询,不是视频流;单次抓屏 ~150–250 ms, 4 路并发在空闲机器上曾测到 13–17 fps,在 load average 129 的机器上只有 6 fps(8 路并发反而更差,5.4 fps,已饱和)。 提高帧率只能换技术路线(ScreenCaptureKit 抓 Simulator 窗口),不是调参能解决的。

刻意不做的事:

  • 不自动关你的模拟器。 收起面板只停采集,设备保持原状——关掉别人的机器是有损操作,不属于插件的权力。
  • 不自动开安卓模拟器。 adb 没有一步启动接口;emulator -avd 要自己管 AVD 名与进程生命周期,放进来会让插件承担一个它管不好的职责。安卓设备需在外部启动。
  • 不在全局按钮上做设备管理。 设备属于某个会话、某个窗口,把它挂在全局浮层上就分不清"这是我哪个会话的机器"。入口只在窗口里。
  • 不常驻采集。 面板收起后 15 s 内没再打开就停,不做后台常开。

为什么是 MJPEG

两个平台的采集原语不对称,取二者的最小公倍数只能是 JPEG 帧:

平台 采集方式 实测(跑出来的)
Android adb exec-out screenrecord --output-format=h264 → ffmpeg 转 JPEG 采集 19.4 fps;经插件按 10 fps 节流后交付 8.9 fps,首帧 67 ms(靠快照预热)
Android adb exec-out screencap -p 轮询(不采用) 3.7 fps(4 路并发)
iOS 4 路并发 simctl io screenshot --type=jpeg → ffmpeg 缩放 热态 15.5 fps;冷启首帧约 2.1 s
iOS simctl io recordVideo 不支持写 stdout,不可直播
iOS idb video-stream 本机 Xcode 26.6 下输出 0 字节,已坏

帧产率由服务端定时器决定(fps 参数),生产者只负责"覆盖最新一帧"。慢客户端不会堆陈旧帧——这是"跟手"而不是"延迟 5 秒"的关键。

静止画面不出帧,以及为什么需要预热

screenrecord 与 recordVideo 都是变化驱动:画面静止时一帧都不出。这对镜像是好事(不浪费),但意味着刚打开时没有首帧。

所以 Android 生产者启动时先做一次 screencap -p + 一次性 ffmpeg 缩放,把这张快照当作首帧发出去;之后屏幕一变,h264 立刻接管。这就是上表里 67 ms 首帧的来源。

画面静止时表现就是画面停住——正确行为,不是卡死。判断"还活着"请看面板的帧计数。


Agent 工具:默认的调用方式

这个插件以前只有界面。现在它同时是 Agent 操作模拟器的默认入口——有工具,才谈得上"优先用它"。

工具 作用
device_mirror_devices 列出 iOS 模拟器 / 安卓设备与 AVD,含状态与原生分辨率
device_mirror_boot 启动模拟器或 AVD(幂等;安卓以 detached 启动,插件 reload 不会杀掉它)
device_mirror_tap 点按。可给归一化 x/y,也可以给文字标签,先定位再点
device_mirror_swipe 两点间滑动(归一化)
device_mirror_text 向聚焦控件输入文字
device_mirror_key BACK / HOME / APP_SWITCH(iOS 的 APP_SWITCH 返回 unsupported)
device_mirror_find 按无障碍文字定位元素,返回像素与归一化中心
device_mirror_snapshot 存一张整屏截图并返回路径,供 read_image 查看
device_mirror_describe 整屏无障碍树:文字、标签、类名、可点击、像素与归一化中心
device_mirror_app install / launch / terminate / open_url
device_mirror_logs iOS simctl spawn … log show,安卓 logcat -d,可按子串过滤
device_mirror_avd 安卓 AVD:list / start / stop / create

坐标一律归一化 0..1,和浏览器里那套完全一致——Agent 不需要知道对面是点还是像素。

工具描述里显式写了 "prefer this over raw adb/simctl",所以模型在选择时会被引导到这里。 真正约束力的做法是在 ~/.agents/rules 里加一条规则并重跑 sync-rules.sh;那是全局配置改动,需要你点头。

与模拟器 helper 插件的关系

本机另装了两个 helper:

插件 它擅长的
@zhz8888/dsh-ios-simulator-helper Xcode 工程发现/脚手架、xcodebuild 构建、装包、启动、日志、无障碍定位
@zhz8888/dsh-android-emulator-helper Gradle 工程发现/脚手架、构建、装包、启动、日志、无障碍定位

分工建议(不要合并成一个大插件):

层 归属
看与摸:实时镜像、按会话分窗、点/滑/输入、创建与启动设备、截图 本插件
造与跑:工程脚手架、编译、装包、启动 App、拉日志 helper 插件

已移植(12 个工具):设备列举与启动、点/滑/输入/按键、无障碍定位与整屏 dump、 单帧截图、应用生命周期(装/启动/终止/开 URL)、日志、安卓 AVD 的 list/start/stop/create。 命令都取自 helper 插件的真实调用,两端走同一套参数。

仍未移植:xcodebuild / gradle 构建、工程发现、App 脚手架。 理由不是偷懒——构建的输出是产物与失败日志,一次编译可能阻塞几分钟,和"看着设备操作"完全不是同一类失败模式; 混在一起会让一次构建失败拖垮镜像会话。这两个能力继续由 helper 插件提供,分工不变。

接口

前缀 /api/device-mirror,全部同源校验(跨源 403)。

方法 路径 说明
GET /devices 列出 adb 在线设备与全部 CoreSimulator 设备(含 Booted 标记、原生分辨率)
GET /stream?platform=&device=&fps=&width=&quality= multipart/x-mixed-replace JPEG 流
POST /input {platform, device, kind: tap|swipe|text|key, x, y, x2, y2, duration, text, key}
POST /boot {platform:'ios', device:<udid>} 启动模拟器,轮询到 Booted 后返回;已启动时立即返回 bootedInMs:0
POST /shutdown 同上,关闭模拟器
GET /catalog 可用的设备模板(124 种)与已安装的系统版本,供新建模拟器用
POST /create {deviceTypeIdentifier, runtimeIdentifier, name?} → simctl create,返回新 udid(不 boot)
GET /android/catalog 已安装的系统镜像与可选机型(来自 SDK 目录 + avdmanager list device)
POST /android/create {imageId, deviceId?, name?} → avdmanager create avd,返回 AVD 名(不启动)
POST /android/boot {name} → detached 启动 emulator -avd,等到系统就绪后返回 adb 序列号
GET /state 生产者、订阅者、帧计数、最近错误、客户端启动信标
GET /report 客户端两半的自报信标(诊断用)

坐标一律是归一化 0..1:客户端不需要知道对面是点还是像素,Host 在 injectAndroid / injectIos 里换算一次。

  • Android:adb shell input tap|swipe|text|keyevent,坐标 = 设备像素(1080×2400 → (0.5,0.5) = 540,1200)
  • iOS:idb ui tap|swipe|text|key,坐标 = 逻辑点(1206×2622 px ÷ 3 → 402×874 pt,(0.5,0.5) = 201,437)

⚠️ iOS tap 必须带 --duration

idb ui tap 的默认按压时长太短,UISwitch 会静默丢弃这次点击: 同一坐标连点 10 次只翻转 1 次;加 --duration 0.05~0.2 后 15/15 全中。 两种情况的 exit code 都是 0,所以不报错、不退码、UI 不动——最难查的那类失败。

本插件对所有 iOS tap / key 统一加 --duration 0.1,代价约 +100 ms。 idb ui describe-all 也验证可用(353–424 ms,返回逻辑点坐标),但本插件用的是更便宜的 idb describe --json 取 width_points / height_points。


安装

# 在 DSH 里:
plugin_manager  action=install_bundle  target=/绝对路径/dsh-device-mirror

装完刷新页面:右下角出现 + 按钮,并自动开好第一个镜像窗口。

使用

  1. 会话里还没有窗口时,点右下角虚线胶囊「设备镜像 +」开第一个
  2. 单击窗口标题栏 → 设备管理器:选运行中的设备、启动关机的模拟器,或新建模拟器
  3. 新建时选模板与系统版本 → 「创建并启动」,新机自动绑定到当前窗口并出流
  4. 管理器底部「再开一窗」可以在本会话再开一个窗口,各自绑不同设备
  5. 画面出来后:点一下 = tap,按住拖 = swipe,⌨ 切出文字输入行与三个导航键

拖动某个窗口的标题栏可移动它;窗口集合、位置与隐藏状态按会话存在 localStorage(键 dsh-device-mirror:panels:<sessionId>),换会话就是另一套窗口。


已知边界

  • iOS 触摸依赖 idb。idb 在这台机器上部分子命令已失效(video-stream 确认坏);ui tap 的通断见 test 与运行时 /state 的 lastInput。失败时面板底部会红字显示原始错误,不静默。
  • iOS 输入坐标系是逻辑点,由 idb describe 或"截图尺寸 ÷3"推断;推断值会以 assumedScale 出现在 /devices,猜错时点按会偏移。
  • 静态画面不出帧(见上)。想看"是否还活着"请看面板的帧计数和状态点。
  • 采集按 platform:device 引用计数:所有窗口离开同一设备 15 s 后才停采集;停止时会连同设备上的 screenrecord 一起收掉。
  • 插件卸载 / reload 时会 SIGKILL 掉自己起的 adb / ffmpeg / simctl 子进程,并对安卓端补一刀 pkill -INT screenrecord(杀掉本地 adb 不会让设备上的录制退出)。
  • iOS 与 Android 的创建是两套工具链。 iOS 用 simctl create(秒级);Android 用 avdmanager create avd + emulator -avd,首次启动实测 50–68 秒,且必须等到 sys.boot_completed 才能出流——只等 adb 看见设备是不够的(实测此时 wm size 会报 Can't find service: window)。
  • AVD 名不是设备 id。 停止的 AVD 只能按名字寻址,而推流/点击要的是 adb 序列号;/android/boot 负责这个换算并返回序列号,窗口绑的是序列号。
  • 启动的模拟器不归插件管。 emulator 以 detached 方式拉起,插件 reload 不会杀掉它——那是用户的机器,不是采集子进程。
  • /boot 已实测:真实冷启动 1514 ms,二次调用幂等返回 bootedInMs:0。
  • 未实测:simctl boot 在"已启动"时的报错文案(只按 current state: <State> 片段容错,文案变了会退化成 502);60 s 超时 → 409 的分支需真卡住才能触发;Shutting Down 中间态下 shutdown 可能返回 502。

已知的实现细节与边界

这一节记录审计时确认过的东西,免得以后重新推一遍。

资源

项 现状
生产者重启 handleStream 在等首帧之前就启动生产者。若首帧超时(504)或浏览器提前断开,没人会订阅,所以两个失败出口都会 armIdleStop()——否则 3 个 simctl 会一直跑。刻意不在启动时就计时:首帧预算 20 s 比空闲宽限 15 s 长,早计会误杀"只是慢"的生产者
首帧等待中断 等待结束后先看 res.destroyed,已断开就直接返回,不再往死 socket 写
Blob URL 每帧新建一个,上一帧延迟 1.2 s 回收;关闭窗口时最后一张也显式回收(之前会泄漏一张解码后的图)
子进程 面板卸载 / 插件 reload 时 SIGKILL 自己的 adb/ffmpeg/simctl,安卓端另补一刀 pkill -INT screenrecord

失败可见性

场景 表现
流断了但画面还在 画面会冻结在最后一帧,所以状态栏下方出一条红条「连接中断:<原因>」+ 重试
输入失败 / 超时 画面上方短暂红条,超时回 504 并写明"设备或这台机器太忙"
平台不支持某键 400 {unsupported:true},按钮本身在 iOS 上就置灰
/state 生产者、订阅者、帧数、重启次数、最近错误、客户端信标

明确不做 / 评估过但不值得

项 结论
客户端按帧拆子组件减少重渲染 当前 7–8 fps × 少数窗口,实测重渲染成本在毫秒内;拆分会把流状态机撕成两半,收益不抵复杂度
打开流时复用几何缓存 热态打开已 0.22 s,其中探测约占 0.1 s,不值得引入缓存失效问题
Android 帧率 screenrecord 在本机持续拖拽时约 6 fps(720x1600、6 秒 34 帧),是编码器上限;fps 重采样反而更差(34 → 14 帧)
iOS 实时拖动 adb input motionevent 与 iOS 均无实时手势通道,滑动只能抬手整段发

代码结构

Host 半从单文件拆成了入口 + 两个模块。两个模块都通过依赖注入拿到 Host 的助手, 不反向 import 入口文件——没有循环依赖,也没有「声明在使用之后」的 TDZ 风险。

文件 行数 职责
index.js 现数 apply、路由表、模拟器生命周期与 AVD 创建、/state
host/devices.js 现数 设备发现、几何换算、输入注入(含 iOS 返回定位)
host/producers.js 现数 Producer 基类、iOS/Android 采集、ffmpeg 管道、空闲停止
host/tools.js 现数 12 个 Agent 工具 + 移植来的设备操作
host/emulator-grpc.js 现数 模拟器 gRPC 触摸桥(手写 protobuf + 内置 http2)
client.js 现数 浮窗、管理器、手势回显、实时触摸

每个模块都通过依赖注入拿到 Host 的助手,不反向 import 入口文件——没有循环依赖, 也没有"声明在使用之后"的 TDZ 风险。两个工厂的调用顺序要紧:createDevices 必须在 createProducers 之前,因为后者要拿 simulatorState。

拆这个顺序踩了两次坑,都是同一类:微信手动列举依赖会漏。

  1. 第一次漏 MIN_FPS 等 7 个采集选项常量 → 流请求 500;
  2. 补完后漏 LatestFrame / encodeSingleFrame / simctlFirstError / simulatorState → 还是 500。

两次都是运行时报错(没有静默),但都逃过了单测——因为单测只覆盖 JSON 路由,没有覆盖 /stream。 现在有一条静态检查守在测试里:扫描 host/*.js 的每个标识符,凡是入口文件声明过、 却没在本文件声明/注入/导入的,直接失败。这条检查当场抓出了那 4 个符号。

开发

npm test   # host 25 项(JPEG 帧重整、尺寸解析、路由面、跨源拒绝、boot/shutdown、iOS 与 Android 创建校验)
           # client 31 项(模块契约、multipart、只注册浮窗、多窗口增删拖隐藏、按会话隔离、AVD 名→序列号、导航键常驻、手势回显、控件不进画面)

test 里用真实 http.Server 调 webServer.register 交回来的 handler,不需要起 DSH。

—/ 5

No ratings yet

Verified DSH bundle

Commit 991ae35fe899

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout