@ponydev/dsh-phone 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/docs/screenshots/02-preview.png +0 -0
- package/docs/screenshots/03-overview.png +0 -0
- package/lib/adb.js +9 -0
- package/lib/client.js +99 -11
- package/lib/index.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
### 新增
|
|
6
|
+
- 打开手机面板自动进入预览:优先本会话上次使用的设备(localStorage 按 sessionId 记忆),否则第一台在线设备
|
|
7
|
+
- 顶部清晰度右侧显示实时 **FPS** 和 **adb 往返延迟**(每 5s 探测一次)
|
|
8
|
+
- 没有可用设备时,居中空态 + 「+ 连接设备」按钮
|
|
9
|
+
|
|
10
|
+
### 变更
|
|
11
|
+
- 「设备列表」仍可从左上角设备下拉进入;自动进入只发生在面板首次挂载
|
|
12
|
+
|
|
13
|
+
## 0.2.2 — 2026-10-24
|
|
14
|
+
|
|
15
|
+
### 修复
|
|
16
|
+
- 客户端 bundle 的 `__ModuleLoader__` id 从 `dsh-phone` 改为 `@ponydev/dsh-phone`,修复迁移后 client-modules 报 “loaded without registering” 导致插件加载失败
|
|
17
|
+
|
|
3
18
|
## 0.2.1 — 2026-10-24
|
|
4
19
|
|
|
5
20
|
### 变更
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ DSH Web 插件:在右侧栏预览已连接的 Android 设备,点画面就是
|
|
|
15
15
|
|
|
16
16
|
- **会话头部手机图标**:点一下展开右侧栏,角标显示在线设备数。
|
|
17
17
|
- **设备列表**:平铺一列,不按手机类型分组;图标按设备系统显示(Android / HarmonyOS / iOS),旁边两个小标签分别标连接方式(USB / Wi-Fi / Tailscale / emulator)和系统(Android / HarmonyOS / iOS),状态点区分 `device` / `unauthorized` / `offline` / `已保存`。
|
|
18
|
-
- **预览**:H.264 流(`screenrecord` → WebCodecs → canvas),约 20–30fps;不支持 WebCodecs
|
|
18
|
+
- **预览**:H.264 流(`screenrecord` → WebCodecs → canvas),约 20–30fps;不支持 WebCodecs 时自动回退截图轮询。打开面板会自动进入预览(本会话上次设备 → 第一台在线设备)。
|
|
19
19
|
- **点按**:点击 = `tap`,拖拽 = `swipe`(坐标按 `wm size` 的设备像素换算,和流分辨率无关)。
|
|
20
20
|
- **操作栏**:底部是「返回 / 主页 / 多任务 | 截图」,顶部只有设备下拉、旋转、更多。
|
|
21
21
|
- **连接**:`adb connect <ip>:<port>`(无线调试、Tailscale 云手机);连接过的目标自动记到 `$DSH_HOME/phone/targets.json`,未连接时也显示在列表里,点一下就直接连。
|
|
Binary file
|
|
Binary file
|
package/lib/adb.js
CHANGED
|
@@ -285,6 +285,15 @@ export class Adb {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/** 一次最小 adb round-trip,用来估算设备/网络延迟(ms)。 */
|
|
289
|
+
async ping(serial) {
|
|
290
|
+
await this.assertReady(serial)
|
|
291
|
+
const started = process.hrtime.bigint()
|
|
292
|
+
await this._run(['-s', serial, 'shell', 'echo', 'ok'], { timeout: 5000 })
|
|
293
|
+
const ms = Number(process.hrtime.bigint() - started) / 1e6
|
|
294
|
+
return { ms: Math.round(ms), at: Date.now() }
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
/** 系统平台(android / harmony),getprop 读一次缓存 5 分钟。 */
|
|
289
298
|
async platform(serial) {
|
|
290
299
|
const cached = this._platforms.get(serial)
|
package/lib/client.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - 画面:轮询 /phone/shot(PNG),点击 = tap,拖拽 = swipe
|
|
5
5
|
// - 没有分辨率/网速元数据栏,没有文字输入框
|
|
6
6
|
window.__ModuleLoader__.load({
|
|
7
|
-
id: 'dsh-phone',
|
|
7
|
+
id: '@ponydev/dsh-phone',
|
|
8
8
|
factory(require) {
|
|
9
9
|
const React = require('react')
|
|
10
10
|
const h = React.createElement
|
|
@@ -127,6 +127,10 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
.dph-select .nm{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
128
128
|
.dph-quality{height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l1,#e9e9ec);border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary,#6f6f76);font-size:11.5px;cursor:pointer;flex:none}
|
|
129
129
|
.dph-quality:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#17171a)}
|
|
130
|
+
.dph-perf{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9aa1);white-space:nowrap;margin-left:2px;font-variant-numeric:tabular-nums}
|
|
131
|
+
.dph-empty{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#9a9aa1)}
|
|
132
|
+
.dph-empty-icon{color:var(--dsw-alias-label-tertiary,#9a9aa1);opacity:.7}
|
|
133
|
+
.dph-empty-title{font-size:13px;color:var(--dsw-alias-label-secondary,#6f6f76)}
|
|
130
134
|
.dph-select .chev{color:var(--dsw-alias-label-tertiary,#9a9aa1);flex:none}
|
|
131
135
|
.dph-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
|
|
132
136
|
|
|
@@ -389,6 +393,7 @@ window.__ModuleLoader__.load({
|
|
|
389
393
|
disconnect: (serial) => unwrap('disconnect', { serial }),
|
|
390
394
|
input: (serial, event) => unwrap('input', { serial, event }),
|
|
391
395
|
screenInfo: (serial) => unwrap('screen-info', { serial }),
|
|
396
|
+
ping: (serial) => unwrap('ping', { serial }),
|
|
392
397
|
targets: () => unwrap('targets'),
|
|
393
398
|
removeTarget: (address) => unwrap('targets/remove', { address }),
|
|
394
399
|
shot: async (serial) => {
|
|
@@ -644,7 +649,7 @@ window.__ModuleLoader__.load({
|
|
|
644
649
|
return { codec, description }
|
|
645
650
|
}
|
|
646
651
|
|
|
647
|
-
function useStream(serial, visible, canvasRef, onToast, attempt, quality, onSize) {
|
|
652
|
+
function useStream(serial, visible, canvasRef, onToast, attempt, quality, onSize, onFrame) {
|
|
648
653
|
const [status, setStatus] = useState('idle')
|
|
649
654
|
useEffect(() => {
|
|
650
655
|
if (!serial || !visible || !CAN_STREAM) {
|
|
@@ -688,6 +693,7 @@ window.__ModuleLoader__.load({
|
|
|
688
693
|
}
|
|
689
694
|
frames += 1
|
|
690
695
|
waitingKey = false
|
|
696
|
+
if (onFrame) onFrame()
|
|
691
697
|
if (!reportedSize) {
|
|
692
698
|
reportedSize = true
|
|
693
699
|
if (onSize) onSize(frame.displayWidth, frame.displayHeight)
|
|
@@ -804,9 +810,23 @@ window.__ModuleLoader__.load({
|
|
|
804
810
|
const [loading, setLoading] = useState(true)
|
|
805
811
|
const [attempt, setAttempt] = useState(0)
|
|
806
812
|
const [toast, setToast] = useState(null)
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
})
|
|
813
|
+
const [fps, setFps] = useState(0)
|
|
814
|
+
const [rtt, setRtt] = useState(null)
|
|
815
|
+
const frameCounter = useRef({ count: 0, at: typeof performance !== 'undefined' ? performance.now() : Date.now() })
|
|
816
|
+
const streamStatus = useStream(
|
|
817
|
+
CAN_STREAM ? serial : null,
|
|
818
|
+
visible,
|
|
819
|
+
canvasRef,
|
|
820
|
+
setToast,
|
|
821
|
+
attempt,
|
|
822
|
+
quality,
|
|
823
|
+
(w, h) => {
|
|
824
|
+
if (w > 0 && h > 0) setRatio(w / h)
|
|
825
|
+
},
|
|
826
|
+
() => {
|
|
827
|
+
frameCounter.current.count += 1
|
|
828
|
+
},
|
|
829
|
+
)
|
|
810
830
|
|
|
811
831
|
useEffect(() => {
|
|
812
832
|
if (!serial) return undefined
|
|
@@ -825,6 +845,35 @@ window.__ModuleLoader__.load({
|
|
|
825
845
|
}
|
|
826
846
|
}, [serial])
|
|
827
847
|
|
|
848
|
+
useEffect(() => {
|
|
849
|
+
const timer = setInterval(() => {
|
|
850
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
|
851
|
+
const state = frameCounter.current
|
|
852
|
+
const seconds = Math.max(0.2, (now - state.at) / 1000)
|
|
853
|
+
setFps(Math.round(state.count / seconds))
|
|
854
|
+
state.count = 0
|
|
855
|
+
state.at = now
|
|
856
|
+
}, 1000)
|
|
857
|
+
return () => clearInterval(timer)
|
|
858
|
+
}, [])
|
|
859
|
+
|
|
860
|
+
useEffect(() => {
|
|
861
|
+
if (!serial || !visible) return undefined
|
|
862
|
+
let alive = true
|
|
863
|
+
const tick = async () => {
|
|
864
|
+
try {
|
|
865
|
+
const value = await api.ping(serial)
|
|
866
|
+
if (alive && value && typeof value.ms === 'number') setRtt(value.ms)
|
|
867
|
+
} catch {}
|
|
868
|
+
}
|
|
869
|
+
tick()
|
|
870
|
+
const timer = setInterval(tick, 5000)
|
|
871
|
+
return () => {
|
|
872
|
+
alive = false
|
|
873
|
+
clearInterval(timer)
|
|
874
|
+
}
|
|
875
|
+
}, [serial, visible])
|
|
876
|
+
|
|
828
877
|
useEffect(() => {
|
|
829
878
|
const el = stageRef.current
|
|
830
879
|
if (!el) return undefined
|
|
@@ -873,6 +922,7 @@ window.__ModuleLoader__.load({
|
|
|
873
922
|
setFrameUrl(url)
|
|
874
923
|
setError(null)
|
|
875
924
|
setLoading(false)
|
|
925
|
+
frameCounter.current.count += 1
|
|
876
926
|
} catch (err) {
|
|
877
927
|
if (!alive) return
|
|
878
928
|
setError(err && err.message ? err.message : 'shot failed')
|
|
@@ -992,6 +1042,8 @@ window.__ModuleLoader__.load({
|
|
|
992
1042
|
error: errorNow,
|
|
993
1043
|
loading: loadingNow,
|
|
994
1044
|
toast,
|
|
1045
|
+
fps,
|
|
1046
|
+
rtt,
|
|
995
1047
|
setToast,
|
|
996
1048
|
send,
|
|
997
1049
|
screenshot,
|
|
@@ -1003,7 +1055,7 @@ window.__ModuleLoader__.load({
|
|
|
1003
1055
|
}
|
|
1004
1056
|
|
|
1005
1057
|
function PhoneTabBody(props) {
|
|
1006
|
-
const { t, useTabInfo, floatTab } = props
|
|
1058
|
+
const { t, useTabInfo, floatTab, sessionId } = props
|
|
1007
1059
|
const info = typeof useTabInfo === 'function' ? useTabInfo() : null
|
|
1008
1060
|
const tab = info && info.tab ? info.tab : null
|
|
1009
1061
|
const visible = tab ? tab.visible !== false : true
|
|
@@ -1035,6 +1087,7 @@ window.__ModuleLoader__.load({
|
|
|
1035
1087
|
}
|
|
1036
1088
|
})
|
|
1037
1089
|
const [disconnectArmed, setDisconnectArmed] = useState(false)
|
|
1090
|
+
const autoSelected = useRef(false)
|
|
1038
1091
|
const { devices, error, reload } = useDeviceList(visible, nonce)
|
|
1039
1092
|
const quality = selected && QUALITY_ORDER.includes(qualityMap[selected]) ? qualityMap[selected] : 'medium'
|
|
1040
1093
|
const setQualityForSelected = (next) => {
|
|
@@ -1109,14 +1162,46 @@ window.__ModuleLoader__.load({
|
|
|
1109
1162
|
}
|
|
1110
1163
|
}
|
|
1111
1164
|
|
|
1165
|
+
const rememberDevice = (serial) => {
|
|
1166
|
+
try {
|
|
1167
|
+
const raw = JSON.parse(localStorage.getItem('dsh-phone-last-device') || '{}')
|
|
1168
|
+
const map = raw && typeof raw === 'object' ? raw : {}
|
|
1169
|
+
if (sessionId) map[sessionId] = serial
|
|
1170
|
+
map['*'] = serial
|
|
1171
|
+
localStorage.setItem('dsh-phone-last-device', JSON.stringify(map))
|
|
1172
|
+
} catch {}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
useEffect(() => {
|
|
1176
|
+
if (autoSelected.current) return undefined
|
|
1177
|
+
const list = rows
|
|
1178
|
+
if (!list || list.length === 0) return undefined
|
|
1179
|
+
let remembered = null
|
|
1180
|
+
try {
|
|
1181
|
+
const map = JSON.parse(localStorage.getItem('dsh-phone-last-device') || '{}')
|
|
1182
|
+
remembered = (sessionId && map[sessionId]) || map['*'] || null
|
|
1183
|
+
} catch {}
|
|
1184
|
+
const ready = list.filter((item) => item.state === 'device')
|
|
1185
|
+
const pick = list.find((item) => item.serial === remembered) || ready[0] || list[0]
|
|
1186
|
+
if (pick) {
|
|
1187
|
+
autoSelected.current = true
|
|
1188
|
+
setSelected(pick.serial)
|
|
1189
|
+
}
|
|
1190
|
+
return undefined
|
|
1191
|
+
}, [devices, targets, selected, sessionId])
|
|
1192
|
+
|
|
1112
1193
|
const onSelect = (picked) => {
|
|
1113
1194
|
setMenuOpen(false)
|
|
1114
1195
|
if (picked && picked.saved) {
|
|
1115
1196
|
connectTo(picked.serial).then((ok) => {
|
|
1116
|
-
if (ok)
|
|
1197
|
+
if (ok) {
|
|
1198
|
+
rememberDevice(picked.serial)
|
|
1199
|
+
setSelected(picked.serial)
|
|
1200
|
+
}
|
|
1117
1201
|
})
|
|
1118
1202
|
return
|
|
1119
1203
|
}
|
|
1204
|
+
rememberDevice(picked.serial)
|
|
1120
1205
|
setSelected(picked.serial)
|
|
1121
1206
|
}
|
|
1122
1207
|
|
|
@@ -1483,9 +1568,10 @@ window.__ModuleLoader__.load({
|
|
|
1483
1568
|
: rows.length === 0
|
|
1484
1569
|
? h(
|
|
1485
1570
|
'div',
|
|
1486
|
-
{ className: 'dph-
|
|
1487
|
-
h('div',
|
|
1488
|
-
h('
|
|
1571
|
+
{ className: 'dph-empty' },
|
|
1572
|
+
h('div', { className: 'dph-empty-icon' }, h(Icon, { name: 'phone', size: 30 })),
|
|
1573
|
+
h('div', { className: 'dph-empty-title' }, t('empty')),
|
|
1574
|
+
h('button', { type: 'button', className: 'dph-primary', onClick: () => setConnectOpen(true) }, '+ ' + t('add')),
|
|
1489
1575
|
)
|
|
1490
1576
|
: h(DeviceRows, { t, devices: rows, selected, onSelect })
|
|
1491
1577
|
|
|
@@ -1571,6 +1657,7 @@ window.__ModuleLoader__.load({
|
|
|
1571
1657
|
qualityLabel(quality, t),
|
|
1572
1658
|
)
|
|
1573
1659
|
: null,
|
|
1660
|
+
selected ? h('span', { className: 'dph-perf', title: t('quality') }, preview.fps + 'fps · ' + (preview.rtt == null ? '—' : preview.rtt + 'ms')) : null,
|
|
1574
1661
|
h('div', { className: 'dph-grow' }),
|
|
1575
1662
|
rightButtons,
|
|
1576
1663
|
),
|
|
@@ -1639,7 +1726,8 @@ window.__ModuleLoader__.load({
|
|
|
1639
1726
|
name: 'sidebar.right.pane.tab',
|
|
1640
1727
|
key: 'dsh-phone',
|
|
1641
1728
|
locale: NS,
|
|
1642
|
-
inject: () => ({
|
|
1729
|
+
inject: (sessionId) => ({
|
|
1730
|
+
sessionId,
|
|
1643
1731
|
floatTab: (tabId, rect) => ctx.sidebarRight.float(tabId, rect),
|
|
1644
1732
|
}),
|
|
1645
1733
|
},
|
package/lib/index.js
CHANGED
|
@@ -92,6 +92,8 @@ export function apply(ctx, rawConfig) {
|
|
|
92
92
|
return ok({ targets: await targets.list() })
|
|
93
93
|
case 'targets/remove':
|
|
94
94
|
return ok({ removed: await targets.remove(String(payload.address || '')) })
|
|
95
|
+
case 'ping':
|
|
96
|
+
return ok(await adb.ping(String(payload.serial || '')))
|
|
95
97
|
case 'screen-info':
|
|
96
98
|
return ok(await adb.screenInfo(String(payload.serial || '')))
|
|
97
99
|
case 'disconnect':
|