中文EN

dsh-device-mirror

dev已测通过npmMIT

把 iOS 模拟器或安卓模拟机投进 DSH 的浮窗,可同时开多个,并在投屏画面上点、拖、打字,输入回传到设备。

下载量 / Downloads
1,340
依赖体积 / node_modules
0.13 MB
测试时间 / Tested
2026-11-06
DSH 版本 / Version
0.2.2-rc.1

安装

dsh plugin --profile web add dsh-device-mirror

沙箱测试结果

阶段结果
1 安装✓
2 列装✓
3 挂载✓
4 启动—

四阶段在隔离沙箱里跑:安装(有网)→ 列装 → 挂载 → 启动(后三步断网)。

README

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

<p>

<a href="https://www.npmjs.com/package/dsh-device-mirror"><img alt="npm" src="https://img.shields.io/npm/v/dsh-device-mirror?color=2f81f7&label=npm"></a>

<a href="https://www.npmjs.com/package/dsh-device-mirror"><img alt="downloads" src="https://img.shields.io/npm/dw/dsh-device-mirror?color=3fb950"></a>

<a href="https://github.com/zengxiaolou/dsh-device-mirror/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/dsh-device-mirror?color=bf8700"></a>

<img alt="dsh plugin" src="https://img.shields.io/badge/DSH-plugin-7aa2ff">

<a href="https://github.com/zengxiaolou/dsh-device-mirror/stargazers"><img alt="stars" src="https://img.shields.io/github/stars/zengxiaolou/dsh-device-mirror?color=8250df"></a>

</p>

把 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 每次是一个一次性进程,注入完就没有了,所以拖动只能"抬手后整段重放"。

模拟器另外提供 `Emula

…(truncated)

以上内容来自 npm 上的 README,我们只做格式化,不改内容。

版本

npm 上共发布:6,最后更新 2026-10-26

链接

GitHub · Homepage · npm

同类插件