@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 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 时自动回退截图轮询。设备画面按右侧栏高度等比缩放,带机身 bezel。
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 streamStatus = useStream(CAN_STREAM ? serial : null, visible, canvasRef, setToast, attempt, quality, (w, h) => {
808
- if (w > 0 && h > 0) setRatio(w / h)
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) setSelected(picked.serial)
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-emptypadding' },
1487
- h('div', null, t('empty')),
1488
- h('button', { type: 'button', className: 'dph-link', style: { marginTop: 8 }, onClick: () => setConnectOpen(true) }, '+ ' + t('add')),
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':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-phone",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "DSH Web 插件:右侧栏预览并点按控制已连接的 Android 设备(adb)",
5
5
  "keywords": [
6
6
  "dsh",