@ponydev/dsh-phone 0.3.2 → 0.4.1

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/AGENTS.md CHANGED
@@ -6,13 +6,15 @@
6
6
 
7
7
  DSH Web 插件:**右侧栏预览并点按控制已连接的 Android 设备**(adb),H.264 实时流 + 无 WebCodecs 时回退截图轮询。
8
8
 
9
- **能力边界**:只认 adb(Android)。hdc(鸿蒙)与 iOS 走 `lib/transport.js` 的 `PhoneTransport` 接口后续接入;UI 容器固定用 DSH 原生 `sidebar-right`,不自造悬浮层。
9
+ **能力边界**:Android 走 adb;iOS 只做**模拟器**(simctl 生命周期 + idb 实时/输入,物理 iPhone 不支持);HarmonyOS 模拟器走 DevEco `Emulator` CLI、真机走 hdc。UI 容器固定用 DSH 原生 `sidebar-right`,不自造悬浮层。
10
10
 
11
11
  ## 1. 目录与职责
12
12
 
13
13
  ```
14
14
  lib/index.js Host 入口(/phone/* RPC、connection.admit 围栏、targets、stream 路由、错误映射)
15
15
  lib/adb.js adb 定位 / devices 解析 / getprop 平台 / wm size / 截图 / 输入 / 串行化
16
+ lib/ios.js iOS 模拟器:simctl 列表/创建/boot/删除/截图 + idb H.264 流/输入
17
+ lib/hos.js HarmonyOS:Emulator CLI(生命周期/创建/截图/点击)+ hdc(真机/截图/uitest)
16
18
  lib/stream.js screenrecord H.264 子进程(续接、背压、断开 kill)、清晰度预设、按比例算 size
17
19
  lib/targets.js 常用连接目标($DSH_HOME/phone/targets.json,0600,只存地址/时间)
18
20
  lib/transport.js PhoneTransport 接口 + AdbTransport(hdc / iOS 的挂载点)
@@ -22,6 +24,7 @@ scripts/smoke.mjs 离线冒烟(解析/白名单/targets/尺寸计
22
24
  scripts/check-i18n.mjs zh/en 字典键一致 + 代码用键存在
23
25
  scripts/release-notes.mjs 从 CHANGELOG 抽取当前版本条目
24
26
  scripts/render-test.mjs 离线渲染 harness(列表/预览/浮窗/诊断/流五种状态)
27
+ scripts/hos-smoke.mjs 鸿蒙 transport 只读冒烟(Emulator CLI + hdc)
25
28
  docs/DESIGN.md 架构与布局;docs/mockups 原型;docs/screenshots 截图
26
29
  ```
27
30
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-10-26
4
+
5
+ ### 修复
6
+ - 鸿蒙模拟器名称规则与 CLI 对齐:只允许字母、数字、空格、下划线 `_`、加号 `+`(之前允许连字符/中文,`-create` 会直接失败);创建表单占位符改为 `dsh_phone_hos`
7
+ - 鸿蒙模拟器 `start` 改为 detached 启动 + 截图 warm-up:`Emulator -start` 是常驻进程,之前前台等待导致 90s 超时;现在启动后等显示服务就绪再返回
8
+
9
+ ### 验证(0.4.1 发布前实测)
10
+ - iOS 模拟器:create → boot → 截图 → H.264 流(idb,100 帧/3s)→ tap / Home 通过;像素→point(density=3)用真实弹窗按钮验证
11
+ - HarmonyOS 模拟器:create → start(45s 含 warm-up)→ 截图 → tap / swipe → stop → delete 全链路通过
12
+ - HarmonyOS 真机(Mate 60 Pro):截图 / keyEvent / tap / text 通过
13
+
14
+ ## 0.4.0 — 2026-10-26
15
+
16
+ ### 新增
17
+ - **iOS 模拟器**:`xcrun simctl` 列表 / 创建 / boot / 关机 / 删除 / 截图;面板可自动进入并轮询预览
18
+ - **HarmonyOS 模拟器**:DevEco `Emulator` CLI 列表 / 创建 / 启动 / 停止 / 删除 / 截图 / 点击 / 滑动 / 旋转 / 电源
19
+ - **HarmonyOS 真机**:`hdc` 设备列表 / 截图 / 点击 / 滑动 / 按键 / 文字输入
20
+ - 「添加设备」菜单:Android 连接 / 创建 iOS 模拟器 / 创建鸿蒙模拟器
21
+ - 设备操作菜单:iOS 模拟器关机 / 删除,鸿蒙模拟器停止 / 删除
22
+ - 平台图标与标签:iOS 模拟器、HarmonyOS 模拟器 / 真机
23
+
24
+ ### 变更
25
+ - Host `/phone/*` 按设备 transport 分发(adb / simctl / emulator / hdc);`health` 返回 iOS 与 HarmonyOS 能力
26
+ - 鸿蒙暂不支持实时流,自动回退截图轮询(iOS 实时流与输入需要 idb,未装时只读预览)
27
+ - 新增 `lib/ios.js`、`lib/hos.js` 与 `scripts/hos-smoke.mjs`
28
+
3
29
  ## 0.3.2 — 2026-10-26
4
30
 
5
31
  ### 变更
package/README.md CHANGED
@@ -110,22 +110,45 @@ DSH 右侧栏第一次打开是**视口宽度的 45%**(1440 屏 ≈ 648px)
110
110
  - 顶部:设备下拉、旋转、更多(电源、重连、诊断、浮窗、复制 serial、设备列表)。
111
111
  - 「断开连接」放在设备下拉菜单里,需要点两次确认(防止误触)。
112
112
 
113
+ ## 平台支持
114
+
115
+ | 平台 | 后端 | 画面 | 操作 | 创建 |
116
+ |---|---|---|---|---|
117
+ | Android 真机 / 云手机 | adb(USB / TCP / Tailscale) | H.264 20–30fps | tap / swipe / 按键 | — |
118
+ | iOS 模拟器 | `xcrun simctl` + idb(可选) | 截图轮询 1–2fps;装 idb 后 H.264 | 装 idb 后 tap / swipe / 按键 | ✅ 选择机型 + 运行时 |
119
+ | HarmonyOS 模拟器 | DevEco `Emulator` CLI | 截图轮询 1–2fps | tap / swipe / 旋转 / 电源 / 按键 / 文字 | ✅ 选择系统镜像 |
120
+ | HarmonyOS 真机 | hdc | 截图轮询 1–2fps | tap / swipe / 按键 / 文字 | — |
121
+
122
+ - iOS 依赖 macOS + Xcode;实时画面/输入需要 `idb`(`brew install facebook/fb/idb-companion` + `pip install fb-idb`),未装时只读预览。
123
+ - 如果 Homebrew 报 "Xcode alone is not sufficient on Tahoe"(需要 Command Line Tools),可改用官方 release 手工安装:
124
+ `gh release download v1.7.7 --repo facebook/idb --pattern 'idb-companion.macos-arm64.tar.gz'`,解压到 `~/.local/lib/idb-companion`,
125
+ 再把 `idb_companion` / `idb-repl` 软链到 `~/.local/bin`;客户端 `python3 -m venv ~/.local/venvs/fb-idb && ~/.local/venvs/fb-idb/bin/pip install fb-idb`。
126
+ - HarmonyOS 依赖 DevEco Studio(`Emulator` CLI)与 `hdc`;hdc 没有 screenrecord,只能截图轮询。
127
+ - 物理 iPhone / iPad 不支持(Apple 未公开投屏/注入 API,Claude Code Desktop 也只支持模拟器)。
128
+
129
+ ## 验证状态
130
+
131
+ - **iOS 模拟器**(iPhone Air / iOS 26.5):create / boot / 截图 / idb H.264 流 / tap / Home 实测通过;像素→point 坐标换算用真实弹窗验证。
132
+ - **HarmonyOS 模拟器**(HarmonyOS 7.0.0):create / start / 截图 / tap / swipe / rotate / stop / delete 全链路实测通过。
133
+ - **HarmonyOS 真机**(Mate 60 Pro,hdc):截图 / keyEvent / tap / text 实测通过。
134
+ - **Android**:真机与云手机的 tap / swipe / back / home / 截图 / H.264 流实测通过。
135
+
113
136
  ## 已知限制
114
137
 
115
- - 画面默认走 H.264 流(约 20–30fps);没有 WebCodecs 的浏览器会回退到 1–2fps 轮询截图。
116
- - 流是 `screenrecord`,没有声音;单次 180s 上限由 Host 自动续接(断开 <1s)。
138
+ - Android 画面默认走 H.264 流(约 20–30fps);没有 WebCodecs 的浏览器会回退到 1–2fps 轮询截图。
139
+ - Android 流是 `screenrecord`,没有声音;单次 180s 上限由 Host 自动续接(断开 <1s)。
117
140
  - 单个右侧栏 tab,设备在 tab 内切换;不支持同一 pane 多设备并排(后续可开 `multiple`)。
118
- - 没有文字输入(`adb shell input text` 只支持 ASCII,中文要 ADBKeyboard)。
141
+ - Android 文字输入(`adb shell input text` 只支持 ASCII,中文要 ADBKeyboard);鸿蒙用 `uitest uiInput text`。
119
142
  - 「已保存」目标是地址级记录,没有备注名;显示名直接是 `ip:port` / 主机名。
120
- - 只支持 Android/adb;hdc(鸿蒙)和 iOS 走 `PhoneTransport` 接口后加。
121
143
  - 右侧栏是 per-session 的,没有会话的页面不显示手机图标。
122
- - 需要设备上已授权 USB 调试;`unauthorized` 会提示去手机上点允许。
144
+ - Android 需要设备上已授权 USB 调试;`unauthorized` 会提示去手机上点允许。
123
145
 
124
146
  ## 开发
125
147
 
126
148
  ```sh
127
149
  npm run check # 语法 + i18n + 隐私/截图门禁
128
150
  npm run smoke # 解析/白名单/真机 adb 冒烟
151
+ npm run smoke:hos # 鸿蒙 Emulator CLI / hdc 只读冒烟(需要 DevEco)
129
152
  node scripts/render-test.mjs [screenshot.png]
130
153
  # 离线渲染列表 + 预览,输出 /tmp/dsh-phone-render.html/.png
131
154
  ```
@@ -136,9 +159,11 @@ node scripts/render-test.mjs [screenshot.png]
136
159
  ## 结构
137
160
 
138
161
  ```
139
- lib/index.js Host:/phone 路由、认证围栏、错误映射
140
- lib/adb.js adb 定位 / devices 解析 / 截图 / 输入 / 串行化
141
- lib/transport.js PhoneTransport 接口 + AdbTransport(hdc / iOS 后加)
162
+ lib/index.js Host:/phone 路由、认证围栏、按 transport 分发
163
+ lib/adb.js Android:adb 定位 / devices 解析 / 截图 / 输入 / 串行化
164
+ lib/ios.js iOS 模拟器:simctl 生命周期/创建/截图 + idb 实时流/输入
165
+ lib/hos.js HarmonyOS:DevEco Emulator CLI + hdc(真机)
166
+ lib/transport.js PhoneTransport 接口 + AdbTransport
142
167
  lib/client.js ModuleLoader bundle:tab 类型、列表、预览、i18n、样式
143
168
  cordis.patch.yml bundle 补丁
144
169
  scripts/ smoke + 离线渲染
package/cordis.patch.yml CHANGED
@@ -3,6 +3,10 @@
3
3
  name: "@ponydev/dsh-phone"
4
4
  config:
5
5
  adbPath: adb
6
+ xcrun: xcrun
7
+ idb: idb
8
+ hdcPath: hdc
9
+ emulatorPath: ''
6
10
  pollMs: 700
7
11
  streamTimeLimit: 180
8
12
  streamBitRate: 6000000
package/docs/DESIGN.md CHANGED
@@ -244,3 +244,23 @@ Host 侧定义 `PhoneTransport { id, list, connect, shot, input }`:
244
244
  2. 设备多时:默认「一个 tab 内切换」(本文)还是「每台设备一个 tab」?
245
245
 
246
246
  (图标位置 = 会话头部工具区、名字 = `dsh-phone`、无元数据列、无输入框,已按你的意思定下。)
247
+
248
+
249
+ ## 多平台 transport(0.4.0)
250
+
251
+ ```
252
+ /phone/* ── resolveDevice(serial) ──┬── adb Android 真机 / 云手机(H.264 screenrecord)
253
+ ├── simctl iOS 模拟器(截图;idb 提供 H.264 + HID)
254
+ ├── emulator HarmonyOS 模拟器(DevEco Emulator CLI)
255
+ └── hdc HarmonyOS 真机(snapshot_display + uitest)
256
+ ```
257
+
258
+ - **iOS**:只支持模拟器。生命周期/创建走公开 `xcrun simctl`;实时画面与点击/输入走 `idb`
259
+ (Meta FBSimulatorControl,Claude Code Desktop 内部也是 CoreSimulator/SimulatorKit 私有栈)。
260
+ idb 的 `video-stream --format h264` 输出 Annex-B,直接复用客户端的 WebCodecs 管线;
261
+ `idb describe` 的 `screen_dimensions.density` 用于像素 → point 坐标换算。
262
+ - **HarmonyOS 模拟器**:`Emulator -list/-create/-start/-stop/-delete/-screenshot/-click/-slide/-rotation/-power`;
263
+ 按键与文字借道 hdc `uitest uiInput`(模拟器没有对应 CLI)。
264
+ - **HarmonyOS 真机**:`hdc list targets` + `snapshot_display` + `uitest uiInput`;没有 screenrecord,只能截图轮询。
265
+ - **物理 iPhone / iPad 不支持**:Apple 没有公开投屏/HID 注入 API(Claude 同样只做模拟器;
266
+ Codex 用 Xcode 27 CoreDevice 私有 HEVC + UniversalHID,不在本项目范围)。
package/lib/client.js CHANGED
@@ -52,6 +52,24 @@ window.__ModuleLoader__.load({
52
52
  diagnostics: 'adb 诊断',
53
53
  quality: '显示清晰度',
54
54
  immersiveOn: '沉浸模式',
55
+ addDevice: '添加设备',
56
+ connectAndroid: '连接 Android 设备',
57
+ createIos: '创建 iOS 模拟器',
58
+ createHos: '创建鸿蒙模拟器',
59
+ hosImage: '系统镜像',
60
+ hosStarting: '正在启动鸿蒙模拟器…',
61
+ stopHos: '关闭鸿蒙模拟器',
62
+ deleteHos: '删除鸿蒙模拟器',
63
+ iosName: '名称',
64
+ iosDeviceType: '机型',
65
+ iosRuntime: '系统版本',
66
+ create: '创建',
67
+ booting: '正在启动模拟器…',
68
+ simulator: '模拟器',
69
+ shutdownSimulator: '关闭模拟器',
70
+ deleteSimulator: '删除模拟器',
71
+ confirmDeleteSimulator: '确认删除?',
72
+ idbHint: 'iOS 输入/实时画面需要 idb',
55
73
  immersiveOff: '显示操作栏',
56
74
  qualityLow: '流畅',
57
75
  qualityMedium: '标准',
@@ -102,6 +120,24 @@ window.__ModuleLoader__.load({
102
120
  diagnostics: 'adb diagnostics',
103
121
  quality: 'Stream quality',
104
122
  immersiveOn: 'Immersive mode',
123
+ addDevice: 'Add device',
124
+ connectAndroid: 'Connect Android device',
125
+ createIos: 'Create iOS simulator',
126
+ createHos: 'Create HarmonyOS emulator',
127
+ hosImage: 'System image',
128
+ hosStarting: 'Starting HarmonyOS emulator…',
129
+ stopHos: 'Shut down HarmonyOS emulator',
130
+ deleteHos: 'Delete HarmonyOS emulator',
131
+ iosName: 'Name',
132
+ iosDeviceType: 'Device type',
133
+ iosRuntime: 'Runtime',
134
+ create: 'Create',
135
+ booting: 'Booting simulator…',
136
+ simulator: 'Simulator',
137
+ shutdownSimulator: 'Shut down simulator',
138
+ deleteSimulator: 'Delete simulator',
139
+ confirmDeleteSimulator: 'Confirm delete?',
140
+ idbHint: 'iOS input/streaming requires idb',
105
141
  immersiveOff: 'Show controls',
106
142
  qualityLow: 'Low',
107
143
  qualityMedium: 'Medium',
@@ -187,6 +223,8 @@ window.__ModuleLoader__.load({
187
223
  .dph-field{display:flex;gap:8px}
188
224
  .dph-input{flex:1;height:32px;border:1px solid var(--dsw-alias-border-l2,#dededf);border-radius:8px;padding:0 10px;font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary,#17171a);background:var(--dsw-alias-bg-layer-1,#fff);outline:none}
189
225
  .dph-input:focus{border-color:var(--dsw-alias-brand-primary,#3b6cff)}
226
+ .dph-select-input{appearance:none;-webkit-appearance:none;padding-right:24px}
227
+ .dph-label{display:block;font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9aa1);margin:10px 0 4px}
190
228
  .dph-primary{height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-label-primary,#17171a);color:var(--dsw-alias-bg-layer-1,#fff);font-size:12px;cursor:pointer}
191
229
  .dph-primary:disabled{opacity:.5;cursor:default}
192
230
  .dph-err{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:11.5px;margin-top:8px;word-break:break-word}
@@ -395,6 +433,17 @@ window.__ModuleLoader__.load({
395
433
  screenInfo: (serial, options) => unwrap('screen-info', { serial, fresh: Boolean(options && options.fresh) }),
396
434
  ping: (serial) => unwrap('ping', { serial }),
397
435
  targets: () => unwrap('targets'),
436
+ iosTypes: () => unwrap('ios/types'),
437
+ iosRuntimes: () => unwrap('ios/runtimes'),
438
+ iosCreate: (payload) => unwrap('ios/create', payload),
439
+ iosBoot: (serial) => unwrap('ios/boot', { serial }),
440
+ iosShutdown: (serial) => unwrap('ios/shutdown', { serial }),
441
+ iosDelete: (serial) => unwrap('ios/delete', { serial }),
442
+ hosImages: (deviceType) => unwrap('hos/images', { deviceType }),
443
+ hosCreate: (payload) => unwrap('hos/create', payload),
444
+ hosStart: (serial) => unwrap('hos/start', { serial }),
445
+ hosStop: (serial) => unwrap('hos/stop', { serial }),
446
+ hosDelete: (serial) => unwrap('hos/delete', { serial }),
398
447
  removeTarget: (address) => unwrap('targets/remove', { address }),
399
448
  shot: async (serial) => {
400
449
  const res = await unwrap('shot', { serial })
@@ -462,6 +511,7 @@ window.__ModuleLoader__.load({
462
511
  if (kind === 'tailscale') return 'Tailscale'
463
512
  if (kind === 'wifi') return 'Wi-Fi'
464
513
  if (kind === 'emulator') return 'emulator'
514
+ if (kind === 'simulator') return 'Simulator'
465
515
  return 'USB'
466
516
  }
467
517
 
@@ -1104,8 +1154,56 @@ window.__ModuleLoader__.load({
1104
1154
  }
1105
1155
  })
1106
1156
  const [disconnectArmed, setDisconnectArmed] = useState(false)
1157
+ const [addMenuOpen, setAddMenuOpen] = useState(false)
1158
+ const [iosCreateOpen, setIosCreateOpen] = useState(false)
1159
+ const [iosTypes, setIosTypes] = useState([])
1160
+ const [iosRuntimes, setIosRuntimes] = useState([])
1161
+ const [iosForm, setIosForm] = useState({ name: '', deviceType: '', runtime: '' })
1162
+ const [iosBusy, setIosBusy] = useState(false)
1163
+ const [simDeleteArmed, setSimDeleteArmed] = useState(false)
1164
+ const [hosCreateOpen, setHosCreateOpen] = useState(false)
1165
+ const [hosImages, setHosImages] = useState([])
1166
+ const [hosForm, setHosForm] = useState({ name: '', osVersion: '' })
1167
+ const [hosBusy, setHosBusy] = useState(false)
1107
1168
  const autoSelected = useRef(false)
1108
1169
  const { devices, error, reload } = useDeviceList(visible, nonce)
1170
+ useEffect(() => {
1171
+ if (!hosCreateOpen) return undefined
1172
+ let alive = true
1173
+ api
1174
+ .hosImages('phone')
1175
+ .catch(() => ({ images: [] }))
1176
+ .then((value) => {
1177
+ if (!alive) return
1178
+ const images = (Array.isArray(value.images) ? value.images : []).filter((item) => item.downloaded)
1179
+ setHosImages(images)
1180
+ setHosForm((prev) => ({ name: prev.name, osVersion: prev.osVersion || (images[0] && images[0].osVersion) || '' }))
1181
+ })
1182
+ return () => {
1183
+ alive = false
1184
+ }
1185
+ }, [hosCreateOpen])
1186
+
1187
+ useEffect(() => {
1188
+ if (!iosCreateOpen) return undefined
1189
+ let alive = true
1190
+ Promise.all([api.iosTypes().catch(() => ({ types: [] })), api.iosRuntimes().catch(() => ({ runtimes: [] }))]).then(([t, r]) => {
1191
+ if (!alive) return
1192
+ const types = Array.isArray(t.types) ? t.types : []
1193
+ const runtimes = Array.isArray(r.runtimes) ? r.runtimes : []
1194
+ setIosTypes(types)
1195
+ setIosRuntimes(runtimes)
1196
+ setIosForm((prev) => ({
1197
+ name: prev.name,
1198
+ deviceType: prev.deviceType || (types[0] && types[0].identifier) || '',
1199
+ runtime: prev.runtime || (runtimes[0] && runtimes[0].identifier) || '',
1200
+ }))
1201
+ })
1202
+ return () => {
1203
+ alive = false
1204
+ }
1205
+ }, [iosCreateOpen])
1206
+
1109
1207
  const quality = selected && QUALITY_ORDER.includes(qualityMap[selected]) ? qualityMap[selected] : 'medium'
1110
1208
  const setQualityForSelected = (next) => {
1111
1209
  if (!selected) return
@@ -1161,6 +1259,8 @@ window.__ModuleLoader__.load({
1161
1259
  const device = rows ? rows.find((item) => item.serial === selected) || null : null
1162
1260
  const tabId = tab && tab.id ? tab.id : null
1163
1261
  const adbMissing = health && health.adb === null
1262
+ const iosReady = Boolean(health && health.ios && health.ios.available)
1263
+ const hosReady = Boolean(health && health.harmony && health.harmony.emulator && health.harmony.emulator.available)
1164
1264
 
1165
1265
  const connectTo = async (address) => {
1166
1266
  setConnecting(true)
@@ -1218,6 +1318,30 @@ window.__ModuleLoader__.load({
1218
1318
  })
1219
1319
  return
1220
1320
  }
1321
+ if (picked && picked.transport === 'emulator' && picked.state !== 'device') {
1322
+ preview.setToast(t('hosStarting'))
1323
+ api
1324
+ .hosStart(picked.serial)
1325
+ .then(() => {
1326
+ rememberDevice(picked.serial)
1327
+ setNonce((value) => value + 1)
1328
+ setSelected(picked.serial)
1329
+ })
1330
+ .catch((err) => preview.setToast(err && err.message ? err.message : 'start failed'))
1331
+ return
1332
+ }
1333
+ if (picked && picked.kind === 'simulator' && picked.state !== 'device') {
1334
+ preview.setToast(t('booting'))
1335
+ api
1336
+ .iosBoot(picked.serial)
1337
+ .then(() => {
1338
+ rememberDevice(picked.serial)
1339
+ setNonce((value) => value + 1)
1340
+ setSelected(picked.serial)
1341
+ })
1342
+ .catch((err) => preview.setToast(err && err.message ? err.message : 'boot failed'))
1343
+ return
1344
+ }
1221
1345
  rememberDevice(picked.serial)
1222
1346
  setSelected(picked.serial)
1223
1347
  }
@@ -1303,6 +1427,44 @@ window.__ModuleLoader__.load({
1303
1427
  },
1304
1428
  },
1305
1429
  { id: 'copy', icon: 'copy', label: t('copySerial'), run: copySerial },
1430
+ ...(device && device.kind === 'simulator'
1431
+ ? [
1432
+ {
1433
+ id: 'shutdown-sim',
1434
+ icon: 'power',
1435
+ label: t('shutdownSimulator'),
1436
+ run: () => {
1437
+ api
1438
+ .iosShutdown(device.serial)
1439
+ .catch(() => {})
1440
+ .then(() => {
1441
+ setSelected(null)
1442
+ setNonce((value) => value + 1)
1443
+ })
1444
+ },
1445
+ },
1446
+ {
1447
+ id: 'delete-sim',
1448
+ icon: 'unlink',
1449
+ label: simDeleteArmed ? t('confirmDeleteSimulator') : t('deleteSimulator'),
1450
+ run: () => {
1451
+ if (!simDeleteArmed) {
1452
+ setSimDeleteArmed(true)
1453
+ setTimeout(() => setSimDeleteArmed(false), 3000)
1454
+ return
1455
+ }
1456
+ setSimDeleteArmed(false)
1457
+ api
1458
+ .iosDelete(device.serial)
1459
+ .catch((err) => preview.setToast(err && err.message ? err.message : 'delete failed'))
1460
+ .then(() => {
1461
+ setSelected(null)
1462
+ setNonce((value) => value + 1)
1463
+ })
1464
+ },
1465
+ },
1466
+ ]
1467
+ : []),
1306
1468
  {
1307
1469
  id: 'list',
1308
1470
  icon: 'back',
@@ -1347,7 +1509,7 @@ window.__ModuleLoader__.load({
1347
1509
  className: 'dph-link',
1348
1510
  onClick: () => {
1349
1511
  setMenuOpen(false)
1350
- setConnectOpen(true)
1512
+ setAddMenuOpen(true)
1351
1513
  },
1352
1514
  },
1353
1515
  '+ ' + t('add'),
@@ -1588,7 +1750,7 @@ window.__ModuleLoader__.load({
1588
1750
  { className: 'dph-empty' },
1589
1751
  h('div', { className: 'dph-empty-icon' }, h(Icon, { name: 'phone', size: 30 })),
1590
1752
  h('div', { className: 'dph-empty-title' }, t('empty')),
1591
- h('button', { type: 'button', className: 'dph-primary', onClick: () => setConnectOpen(true) }, '+ ' + t('add')),
1753
+ h('button', { type: 'button', className: 'dph-primary', onClick: () => setAddMenuOpen(true) }, '+ ' + t('addDevice')),
1592
1754
  )
1593
1755
  : h(DeviceRows, { t, devices: rows, selected, onSelect })
1594
1756
 
@@ -1613,7 +1775,7 @@ window.__ModuleLoader__.load({
1613
1775
  ),
1614
1776
  h(
1615
1777
  'button',
1616
- { key: 'add', type: 'button', className: 'dph-ibtn', title: t('add'), onClick: () => setConnectOpen(true) },
1778
+ { key: 'add', type: 'button', className: 'dph-ibtn', title: t('addDevice'), onClick: () => setAddMenuOpen(true) },
1617
1779
  h(Icon, { name: 'plus' }),
1618
1780
  ),
1619
1781
  ]
@@ -1646,6 +1808,199 @@ window.__ModuleLoader__.load({
1646
1808
  )
1647
1809
  : null
1648
1810
 
1811
+ const hosCreateSheet = hosCreateOpen
1812
+ ? h(
1813
+ 'div',
1814
+ null,
1815
+ h('div', {
1816
+ className: 'dph-backdrop',
1817
+ onClick: () => {
1818
+ if (!hosBusy) setHosCreateOpen(false)
1819
+ },
1820
+ }),
1821
+ h(
1822
+ 'form',
1823
+ {
1824
+ className: 'dph-connect',
1825
+ style: { maxWidth: 380 },
1826
+ onSubmit: async (event) => {
1827
+ event.preventDefault()
1828
+ if (hosBusy) return
1829
+ setHosBusy(true)
1830
+ try {
1831
+ const created = await api.hosCreate({ name: hosForm.name, osVersion: hosForm.osVersion, deviceType: 'phone' })
1832
+ setHosCreateOpen(false)
1833
+ setNonce((value) => value + 1)
1834
+ if (created && created.serial) {
1835
+ rememberDevice(created.serial)
1836
+ setSelected(created.serial)
1837
+ }
1838
+ } catch (error) {
1839
+ preview.setToast(error && error.message ? error.message : 'create failed')
1840
+ } finally {
1841
+ setHosBusy(false)
1842
+ }
1843
+ },
1844
+ },
1845
+ h('div', { className: 'ttl' }, t('createHos')),
1846
+ h('div', { className: 'sub' }, 'DevEco Emulator CLI'),
1847
+ h('label', { className: 'dph-label' }, t('iosName')),
1848
+ h('input', {
1849
+ className: 'dph-input',
1850
+ value: hosForm.name,
1851
+ placeholder: 'dsh_phone_hos',
1852
+ onChange: (event) => setHosForm((prev) => ({ ...prev, name: event.target.value })),
1853
+ spellCheck: false,
1854
+ }),
1855
+ h('label', { className: 'dph-label' }, t('hosImage')),
1856
+ h(
1857
+ 'select',
1858
+ {
1859
+ className: 'dph-input dph-select-input',
1860
+ value: hosForm.osVersion,
1861
+ onChange: (event) => setHosForm((prev) => ({ ...prev, osVersion: event.target.value })),
1862
+ },
1863
+ hosImages.map((item) => h('option', { key: item.osVersion, value: item.osVersion }, item.osVersion)),
1864
+ ),
1865
+ h(
1866
+ 'div',
1867
+ { className: 'dph-field', style: { marginTop: 12 } },
1868
+ h('button', { type: 'submit', className: 'dph-primary', disabled: hosBusy || !hosForm.name.trim() || !hosForm.osVersion }, hosBusy ? t('loading') : t('create')),
1869
+ h('button', { type: 'button', className: 'dph-ghost', onClick: () => setHosCreateOpen(false) }, t('cancel')),
1870
+ ),
1871
+ ),
1872
+ )
1873
+ : null
1874
+
1875
+ const addMenu = addMenuOpen
1876
+ ? h(
1877
+ 'div',
1878
+ null,
1879
+ h('div', { className: 'dph-backdrop', onClick: () => setAddMenuOpen(false) }),
1880
+ h(
1881
+ 'div',
1882
+ { className: 'dph-menu', style: { top: 48, left: 8, width: 270 } },
1883
+ h(
1884
+ 'button',
1885
+ {
1886
+ type: 'button',
1887
+ className: 'dph-row',
1888
+ onClick: () => {
1889
+ setAddMenuOpen(false)
1890
+ setConnectOpen(true)
1891
+ },
1892
+ },
1893
+ h('span', { className: 'dph-av', style: { width: 26, height: 26, borderRadius: 7 } }, h(Icon, { name: 'android', size: 16 })),
1894
+ h('span', { className: 'dph-meta' }, h('span', { className: 'dph-model', style: { fontWeight: 400 } }, t('connectAndroid'))),
1895
+ ),
1896
+ iosReady
1897
+ ? h(
1898
+ 'button',
1899
+ {
1900
+ type: 'button',
1901
+ className: 'dph-row',
1902
+ onClick: () => {
1903
+ setAddMenuOpen(false)
1904
+ setIosCreateOpen(true)
1905
+ },
1906
+ },
1907
+ h('span', { className: 'dph-av', style: { width: 26, height: 26, borderRadius: 7 } }, h(Icon, { name: 'apple', size: 16 })),
1908
+ h('span', { className: 'dph-meta' }, h('span', { className: 'dph-model', style: { fontWeight: 400 } }, t('createIos'))),
1909
+ )
1910
+ : null,
1911
+ hosReady
1912
+ ? h(
1913
+ 'button',
1914
+ {
1915
+ type: 'button',
1916
+ className: 'dph-row',
1917
+ onClick: () => {
1918
+ setAddMenuOpen(false)
1919
+ setHosCreateOpen(true)
1920
+ },
1921
+ },
1922
+ h('span', { className: 'dph-av', style: { width: 26, height: 26, borderRadius: 7 } }, h(Icon, { name: 'harmony', size: 16 })),
1923
+ h('span', { className: 'dph-meta' }, h('span', { className: 'dph-model', style: { fontWeight: 400 } }, t('createHos'))),
1924
+ )
1925
+ : null,
1926
+ ),
1927
+ )
1928
+ : null
1929
+
1930
+ const iosCreateSheet = iosCreateOpen
1931
+ ? h(
1932
+ 'div',
1933
+ null,
1934
+ h('div', {
1935
+ className: 'dph-backdrop',
1936
+ onClick: () => {
1937
+ if (!iosBusy) setIosCreateOpen(false)
1938
+ },
1939
+ }),
1940
+ h(
1941
+ 'form',
1942
+ {
1943
+ className: 'dph-connect',
1944
+ style: { maxWidth: 380 },
1945
+ onSubmit: async (event) => {
1946
+ event.preventDefault()
1947
+ if (iosBusy) return
1948
+ setIosBusy(true)
1949
+ try {
1950
+ const created = await api.iosCreate(iosForm)
1951
+ setIosCreateOpen(false)
1952
+ setNonce((value) => value + 1)
1953
+ if (created && created.serial) {
1954
+ rememberDevice(created.serial)
1955
+ setSelected(created.serial)
1956
+ }
1957
+ } catch (error) {
1958
+ preview.setToast(error && error.message ? error.message : 'create failed')
1959
+ } finally {
1960
+ setIosBusy(false)
1961
+ }
1962
+ },
1963
+ },
1964
+ h('div', { className: 'ttl' }, t('createIos')),
1965
+ h('div', { className: 'sub' }, 'xcrun simctl create'),
1966
+ h('label', { className: 'dph-label' }, t('iosName')),
1967
+ h('input', {
1968
+ className: 'dph-input',
1969
+ value: iosForm.name,
1970
+ placeholder: 'dsh-phone',
1971
+ onChange: (event) => setIosForm((prev) => ({ ...prev, name: event.target.value })),
1972
+ spellCheck: false,
1973
+ }),
1974
+ h('label', { className: 'dph-label' }, t('iosDeviceType')),
1975
+ h(
1976
+ 'select',
1977
+ {
1978
+ className: 'dph-input dph-select-input',
1979
+ value: iosForm.deviceType,
1980
+ onChange: (event) => setIosForm((prev) => ({ ...prev, deviceType: event.target.value })),
1981
+ },
1982
+ iosTypes.map((item) => h('option', { key: item.identifier, value: item.identifier }, item.name)),
1983
+ ),
1984
+ h('label', { className: 'dph-label' }, t('iosRuntime')),
1985
+ h(
1986
+ 'select',
1987
+ {
1988
+ className: 'dph-input dph-select-input',
1989
+ value: iosForm.runtime,
1990
+ onChange: (event) => setIosForm((prev) => ({ ...prev, runtime: event.target.value })),
1991
+ },
1992
+ iosRuntimes.map((item) => h('option', { key: item.identifier, value: item.identifier }, item.name)),
1993
+ ),
1994
+ h(
1995
+ 'div',
1996
+ { className: 'dph-field', style: { marginTop: 12 } },
1997
+ h('button', { type: 'submit', className: 'dph-primary', disabled: iosBusy || !iosForm.name.trim() || !iosForm.deviceType || !iosForm.runtime }, iosBusy ? t('loading') : t('create')),
1998
+ h('button', { type: 'button', className: 'dph-ghost', onClick: () => setIosCreateOpen(false) }, t('cancel')),
1999
+ ),
2000
+ ),
2001
+ )
2002
+ : null
2003
+
1649
2004
  return h(
1650
2005
  'div',
1651
2006
  { className: 'dph-root' },
@@ -1694,6 +2049,9 @@ window.__ModuleLoader__.load({
1694
2049
  moreMenu,
1695
2050
  connectSheet,
1696
2051
  diagSheet,
2052
+ addMenu,
2053
+ iosCreateSheet,
2054
+ hosCreateSheet,
1697
2055
  )
1698
2056
  }
1699
2057