🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

设备镜像浮窗 zengxiaolou/dsh-device-mirror

DeepSeek Harness
zengxiaolou10 天前有提交已提供中文文档扫描:含敏感能力

把模拟器画面投进浮窗,直接点按拖拽和打字

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/24 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add zengxiaolou/dsh-device-mirror
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · ui
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 10 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令发起外部网络请求
查看扫描证据(共 19 处,展示前 15 条)
  • 执行外部命令固定命令
    index.js:91
    const child = spawn(
  • 发起网络请求
    client.js:53
    fetch(`${API}/report?${query.toString()}`, { cache: 'no-store' }).catch(() => {});
  • 发起网络请求
    client.js:227
    const response = await fetch(`${API}/devices`, { cache: 'no-store' });
  • 发起网络请求
    client.js:345
    const response = await fetch(`${API}/input`, {
  • 发起网络请求
    client.js:359
    const response = await fetch(`${API}/boot`, {
  • 发起网络请求
    client.js:389
    const response = await fetch(`${API}/android/boot`, {
  • 发起网络请求
    client.js:406
    const response = await fetch(`${API}/android/create`, {
  • 执行外部命令固定命令
    host/producers.js:253
    spawn(
  • 执行外部命令固定命令
    host/producers.js:269
    const encoder = this.track(spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'pipe', 'pipe'] }));
  • 执行外部命令固定命令
    host/producers.js:334
    spawn(
  • 发起网络请求
    host/emulator-grpc.js:90
    const req = link.session.request({
  • 执行外部命令来自配置/变量
    host/shared.js:49
    execFile(command, args, { timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
  • 执行外部命令来自配置/变量
    host/shared.js:72
    execFile(
  • 执行外部命令来自配置/变量
    host/android-avd.js:64
    child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
  • 执行外部命令来自配置/变量
    host/routes.js:680
    child = spawn(
扫描于 2026/11/3(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

README

由 DeepSeek 最新模型翻译生成
Device Mirror(@local/dsh-device-mirror) dsh-device-mirror 把 iOS 模拟器 / 安卓模拟机的实时画面投进 DSH,还能直接在上面点、拖、打字。 只有一种形态:浮窗,而且可以开多个。 | 项 | 说明 | | 落位 | shell.overlay slot(帧级浮层),一个 slot 承载 N 个窗口 | | 窗口 | 每个窗口绑定一台设备,独立定位、独立拖动、独立隐藏、独立关闭 | | 新增 | 在窗口里的设备管理器创建,没有常驻的全局 + 按钮 | | 依赖 | 无 | 刻意不做右栏 Tab:正在操作一台设备时,你要的是浮在工作区之上,而不是挤进和文件树、diff 共享宽度的窄栏里。 多个窗口共用一份采集:Host 的生产者按 platform:device 建、带订阅者计数,所以同一个设备开两个窗口只采集一次,两个设备开两个窗口各采一份。
展开完整文档(README 剩余 1 节)
Device Mirror(@local/dsh-device-mirror)

dsh-device-mirror

把 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 退出码 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 首帧的来源。

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

智能体工具:默认的调用方式

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

| 工具 | 作用 |
| 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,和浏览器里那套完全一致——智能体不需要知道对面是点还是像素。

工具描述里显式写了 "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。
预览分享卡片长什么样 ↓zengxiaolou/dsh-device-mirror
分享 · DeepSeek Harness Hub
zengxiaolou/dsh-device-mirror

把模拟器画面投进浮窗,直接点按拖拽和打字

这是你的仓库?认领条目

回到顶部