@ponydev/dsh-phone 0.3.2 → 0.4.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/lib/index.js CHANGED
@@ -8,11 +8,17 @@ import { homedir } from 'node:os'
8
8
  import { createTransports } from './transport.js'
9
9
  import { createTargetStore } from './targets.js'
10
10
  import { STREAM_QUALITY, createStreamer, streamSizeFor } from './stream.js'
11
+ import { IosSimulator } from './ios.js'
12
+ import { HarmonyEmulator, HarmonyHdc } from './hos.js'
11
13
 
12
14
  export const name = 'dsh-phone'
13
15
 
14
16
  const DEFAULTS = {
15
17
  adbPath: 'adb',
18
+ xcrun: 'xcrun',
19
+ idb: 'idb',
20
+ hdcPath: 'hdc',
21
+ emulatorPath: '',
16
22
  pollMs: 700,
17
23
  streamTimeLimit: 180,
18
24
  streamBitRate: 6_000_000,
@@ -58,6 +64,9 @@ function json(res, code, payload) {
58
64
  export function apply(ctx, rawConfig) {
59
65
  const config = { ...DEFAULTS, ...(rawConfig || {}) }
60
66
  const { adb } = createTransports({ adbPath: config.adbPath })
67
+ const ios = new IosSimulator({ xcrun: config.xcrun, idb: config.idb })
68
+ const hdc = new HarmonyHdc({ path: config.hdcPath })
69
+ const hosEmulator = new HarmonyEmulator({ path: config.emulatorPath, hdc })
61
70
  const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() ? process.env.DSH_HOME.trim() : join(homedir(), '.dsh')
62
71
  const targets = createTargetStore(join(home, 'phone', 'targets.json'))
63
72
  const streamer = createStreamer(adb, {
@@ -67,21 +76,77 @@ export function apply(ctx, rawConfig) {
67
76
  })
68
77
 
69
78
  const ok = (value) => ({ ok: true, value })
79
+ const fail = (code, message) => {
80
+ const error = new Error(message)
81
+ error.code = code
82
+ return error
83
+ }
84
+
85
+ async function listAllDevices() {
86
+ const [adbResult, iosResult, emulatorResult, hdcResult] = await Promise.allSettled([
87
+ adb.devices({ fresh: true }),
88
+ ios.list(),
89
+ hosEmulator.list(),
90
+ hdc.list(),
91
+ ])
92
+ const devices = []
93
+ if (adbResult.status === 'fulfilled') devices.push(...adbResult.value.devices)
94
+ if (iosResult.status === 'fulfilled') devices.push(...iosResult.value)
95
+ if (emulatorResult.status === 'fulfilled') devices.push(...emulatorResult.value)
96
+ if (hdcResult.status === 'fulfilled') devices.push(...hdcResult.value)
97
+ return devices
98
+ }
99
+
100
+ async function resolveDevice(serial) {
101
+ const devices = await listAllDevices()
102
+ const device = devices.find((item) => item.serial === serial)
103
+ if (!device) throw fail('unknown-device', `设备 ${serial} 不在当前设备列表里`)
104
+ const kind =
105
+ device.transport === 'simctl'
106
+ ? 'ios'
107
+ : device.transport === 'emulator'
108
+ ? 'emulator'
109
+ : device.transport === 'hdc'
110
+ ? 'hdc'
111
+ : 'adb'
112
+ return { kind, device }
113
+ }
70
114
 
71
115
  async function handle(endpoint, payload) {
72
116
  switch (endpoint) {
73
117
  case 'health': {
118
+ let adbInfo = null
119
+ let adbError = null
74
120
  try {
75
- const info = await adb.resolve()
76
- const { devices } = await adb.devices()
77
- return ok({ adb: info, devices: devices.length })
121
+ adbInfo = await adb.resolve()
78
122
  } catch (error) {
79
- return ok({ adb: null, devices: 0, error: messageOf(error) })
123
+ adbError = messageOf(error)
80
124
  }
125
+ const devices = await listAllDevices().catch(() => [])
126
+ const iosAvailable = await ios.available().catch(() => false)
127
+ const idbAvailable = iosAvailable ? await ios.idbAvailable().catch(() => false) : false
128
+ let emulatorInfo = null
129
+ try {
130
+ emulatorInfo = await hosEmulator.resolve()
131
+ } catch {}
132
+ let hdcInfo = null
133
+ try {
134
+ hdcInfo = await hdc.resolve()
135
+ } catch {}
136
+ return ok({
137
+ adb: adbInfo,
138
+ adbError,
139
+ devices: devices.length,
140
+ ios: { available: iosAvailable, idb: idbAvailable, version: ios.idbVersion },
141
+ harmony: {
142
+ emulator: { available: Boolean(emulatorInfo), version: emulatorInfo ? emulatorInfo.version : null },
143
+ hdc: { available: Boolean(hdcInfo), version: hdcInfo ? hdcInfo.version : null },
144
+ },
145
+ })
81
146
  }
82
147
  case 'devices': {
83
- const { devices, at } = await adb.devices({ fresh: true })
84
- return ok({ devices, at })
148
+ const devices = await listAllDevices()
149
+ return ok({ devices, at: Date.now() })
85
150
  }
86
151
  case 'connect': {
87
152
  const result = await adb.connect(String(payload.address || ''))
@@ -92,14 +157,68 @@ export function apply(ctx, rawConfig) {
92
157
  return ok({ targets: await targets.list() })
93
158
  case 'targets/remove':
94
159
  return ok({ removed: await targets.remove(String(payload.address || '')) })
95
- case 'ping':
96
- return ok(await adb.ping(String(payload.serial || '')))
97
- case 'screen-info':
98
- return ok(await adb.screenInfo(String(payload.serial || ''), { fresh: payload.fresh === true }))
160
+ case 'ping': {
161
+ const serial = String(payload.serial || '')
162
+ const { kind } = await resolveDevice(serial)
163
+ if (kind === 'ios') return ok(await ios.ping(serial))
164
+ if (kind === 'emulator') {
165
+ const started = process.hrtime.bigint()
166
+ await hosEmulator.find(serial)
167
+ return ok({ ms: Math.round(Number(process.hrtime.bigint() - started) / 1e6), at: Date.now() })
168
+ }
169
+ if (kind === 'hdc') {
170
+ const started = process.hrtime.bigint()
171
+ await hdc.assertReady(serial)
172
+ return ok({ ms: Math.round(Number(process.hrtime.bigint() - started) / 1e6), at: Date.now() })
173
+ }
174
+ return ok(await adb.ping(serial))
175
+ }
176
+ case 'screen-info': {
177
+ const serial = String(payload.serial || '')
178
+ const { kind } = await resolveDevice(serial)
179
+ if (kind === 'ios') return ok(await ios.screenInfo(serial))
180
+ if (kind === 'emulator') return ok(await hosEmulator.screenInfo(serial, { fresh: payload.fresh === true }))
181
+ if (kind === 'hdc') return ok(await hdc.screenInfo(serial, { fresh: payload.fresh === true }))
182
+ return ok(await adb.screenInfo(serial, { fresh: payload.fresh === true }))
183
+ }
99
184
  case 'disconnect':
100
185
  return ok(await adb.disconnect(String(payload.serial || '')))
101
- case 'input':
102
- return ok(await adb.input(String(payload.serial || ''), payload.event || {}))
186
+ case 'input': {
187
+ const serial = String(payload.serial || '')
188
+ const { kind } = await resolveDevice(serial)
189
+ if (kind === 'ios') return ok(await ios.input(serial, payload.event || {}))
190
+ if (kind === 'emulator') return ok(await hosEmulator.input(serial, payload.event || {}))
191
+ if (kind === 'hdc') return ok(await hdc.input(serial, payload.event || {}))
192
+ return ok(await adb.input(serial, payload.event || {}))
193
+ }
194
+ case 'ios/types':
195
+ return ok({ types: await ios.listDeviceTypes() })
196
+ case 'ios/runtimes':
197
+ return ok({ runtimes: await ios.listRuntimes() })
198
+ case 'ios/create':
199
+ return ok(await ios.create({ name: String(payload.name || ''), deviceType: String(payload.deviceType || ''), runtime: String(payload.runtime || '') }))
200
+ case 'ios/boot':
201
+ return ok(await ios.boot(String(payload.serial || '')))
202
+ case 'ios/shutdown':
203
+ return ok(await ios.shutdown(String(payload.serial || '')))
204
+ case 'ios/delete':
205
+ return ok(await ios.remove(String(payload.serial || '')))
206
+ case 'hos/images':
207
+ return ok({ images: await hosEmulator.imageList({ deviceType: String(payload.deviceType || 'phone') }) })
208
+ case 'hos/create':
209
+ return ok(
210
+ await hosEmulator.create({
211
+ name: String(payload.name || ''),
212
+ osVersion: String(payload.osVersion || ''),
213
+ deviceType: String(payload.deviceType || 'phone'),
214
+ }),
215
+ )
216
+ case 'hos/start':
217
+ return ok(await hosEmulator.start(String(payload.serial || '')))
218
+ case 'hos/stop':
219
+ return ok(await hosEmulator.stop(String(payload.serial || '')))
220
+ case 'hos/delete':
221
+ return ok(await hosEmulator.remove(String(payload.serial || '')))
103
222
  default:
104
223
  return { ok: false, error: { code: 'phone/not-found', message: `未知接口: ${endpoint || '(empty)'}` } }
105
224
  }
@@ -133,6 +252,36 @@ export function apply(ctx, rawConfig) {
133
252
  }
134
253
  if (endpoint === 'stream') {
135
254
  const serial = String(payload.serial || '')
255
+ let resolved = null
256
+ try {
257
+ resolved = await resolveDevice(serial)
258
+ } catch (error) {
259
+ json(res, 200, failOf(error))
260
+ return
261
+ }
262
+ if (resolved.kind === 'emulator' || resolved.kind === 'hdc') {
263
+ json(res, 200, failOf(fail('stream-unsupported', '鸿蒙暂不支持实时流,回退截图轮询')))
264
+ return
265
+ }
266
+ if (resolved.kind === 'ios') {
267
+ if (!(await ios.idbAvailable())) {
268
+ json(res, 200, failOf(fail('idb-missing', 'iOS 实时画面需要 idb(brew install facebook/fb/idb-companion + pip install fb-idb)')))
269
+ return
270
+ }
271
+ res.writeHead(200, {
272
+ 'content-type': 'video/h264',
273
+ 'cache-control': 'no-store',
274
+ 'x-content-type-options': 'nosniff',
275
+ 'x-accel-buffering': 'no',
276
+ })
277
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
278
+ ios.openStream(serial, res, {}).catch(() => {
279
+ try {
280
+ res.end()
281
+ } catch {}
282
+ })
283
+ return
284
+ }
136
285
  try {
137
286
  await adb.assertReady(serial)
138
287
  } catch (error) {
@@ -161,7 +310,16 @@ export function apply(ctx, rawConfig) {
161
310
  }
162
311
  if (endpoint === 'shot') {
163
312
  try {
164
- const buffer = await adb.shot(String(payload.serial || ''))
313
+ const serial = String(payload.serial || '')
314
+ const { kind } = await resolveDevice(serial)
315
+ const buffer =
316
+ kind === 'ios'
317
+ ? await ios.shot(serial)
318
+ : kind === 'emulator'
319
+ ? await hosEmulator.shot(serial)
320
+ : kind === 'hdc'
321
+ ? await hdc.shot(serial)
322
+ : await adb.shot(serial)
165
323
  res.writeHead(200, {
166
324
  'content-type': 'image/png',
167
325
  'content-length': buffer.length,
package/lib/ios.js ADDED
@@ -0,0 +1,327 @@
1
+ // dsh-phone · iOS 模拟器 transport
2
+ // - 生命周期/创建/截图:公开的 xcrun simctl
3
+ // - 实时画面 + 输入:idb(Meta 的 FBSimulatorControl CLI,Claude Code 桌面版内部也是这套私有 CoreSimulator/SimulatorKit 栈)
4
+ // 没有 idb 时降级为「截图轮询 + 只读」,输入会明确报 idb-missing。
5
+ import { execFile, spawn } from 'node:child_process'
6
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ const DEFAULT_TIMEOUT = 20000
11
+ const SHOT_TIMEOUT = 20000
12
+
13
+ function fail(code, message) {
14
+ const error = new Error(message)
15
+ error.code = code
16
+ return error
17
+ }
18
+
19
+ function run(bin, args, options = {}) {
20
+ const timeout = options.timeout || DEFAULT_TIMEOUT
21
+ const maxBuffer = options.maxBuffer || 16 * 1024 * 1024
22
+ return new Promise((resolve, reject) => {
23
+ execFile(bin, args, { timeout, maxBuffer, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => {
24
+ if (error) {
25
+ const text = (stderr && stderr.toString('utf8').trim()) || (stdout && stdout.toString('utf8').trim()) || error.message
26
+ const wrapped = fail(options.code || 'ios-command-failed', text.split(/\r?\n/).slice(0, 3).join(' '))
27
+ wrapped.stdout = stdout
28
+ wrapped.stderr = stderr
29
+ reject(wrapped)
30
+ return
31
+ }
32
+ resolve({ stdout, stderr })
33
+ })
34
+ })
35
+ }
36
+
37
+ function pngSize(buffer) {
38
+ if (!buffer || buffer.length < 24) return null
39
+ if (buffer[0] !== 0x89 || buffer.toString('ascii', 1, 4) !== 'PNG') return null
40
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
41
+ }
42
+
43
+ function parseJson(buffer) {
44
+ try {
45
+ return JSON.parse(buffer.toString('utf8'))
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ export class IosSimulator {
52
+ constructor(options = {}) {
53
+ this.xcrun = options.xcrun || 'xcrun'
54
+ this.idbBin = options.idb || 'idb'
55
+ this.idbChecked = null
56
+ this.idbVersion = null
57
+ this._screens = new Map()
58
+ this._streams = new Map()
59
+ }
60
+
61
+ async simctl(args, options = {}) {
62
+ return run(this.xcrun, ['simctl', ...args], options)
63
+ }
64
+
65
+ async available() {
66
+ try {
67
+ await this.simctl(['list', 'devices', '--json'], { timeout: 8000 })
68
+ return true
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ async idbAvailable() {
75
+ if (this.idbChecked !== null) return this.idbChecked
76
+ try {
77
+ const { stdout } = await run(this.idbBin, ['--help'], { timeout: 8000 })
78
+ const text = stdout.toString('utf8')
79
+ if (!/idb:/.test(text)) throw fail('idb-missing', 'idb --help 输出异常')
80
+ this.idbVersion = 'fb-idb'
81
+ this.idbChecked = true
82
+ } catch {
83
+ this.idbChecked = false
84
+ }
85
+ return this.idbChecked
86
+ }
87
+
88
+ async list() {
89
+ const { stdout } = await this.simctl(['list', 'devices', '--json'])
90
+ const data = parseJson(stdout)
91
+ if (!data || !data.devices) throw fail('ios-list-failed', '无法解析 simctl list devices')
92
+ const runtimes = new Map()
93
+ try {
94
+ const { stdout: rtOut } = await this.simctl(['list', 'runtimes', '--json'])
95
+ const rt = parseJson(rtOut)
96
+ for (const item of (rt && rt.runtimes) || []) runtimes.set(item.identifier, item.name)
97
+ } catch {}
98
+ const devices = []
99
+ for (const [runtimeId, list] of Object.entries(data.devices)) {
100
+ for (const item of list || []) {
101
+ if (!item || !item.udid) continue
102
+ if (item.isAvailable === false) continue
103
+ devices.push({
104
+ serial: item.udid,
105
+ state: item.state === 'Booted' ? 'device' : 'offline',
106
+ model: item.name,
107
+ platform: 'ios',
108
+ kind: 'simulator',
109
+ transport: 'simctl',
110
+ runtime: runtimes.get(runtimeId) || runtimeId,
111
+ booted: item.state === 'Booted',
112
+ })
113
+ }
114
+ }
115
+ return devices
116
+ }
117
+
118
+ async listDeviceTypes() {
119
+ const { stdout } = await this.simctl(['list', 'devicetypes', '--json'])
120
+ const data = parseJson(stdout)
121
+ return ((data && data.devicetypes) || [])
122
+ .filter((item) => /^(iPhone|iPad)/.test(item.name))
123
+ .map((item) => ({ identifier: item.identifier, name: item.name }))
124
+ }
125
+
126
+ async listRuntimes() {
127
+ const { stdout } = await this.simctl(['list', 'runtimes', '--json'])
128
+ const data = parseJson(stdout)
129
+ return ((data && data.runtimes) || [])
130
+ .filter((item) => item.isAvailable !== false && /iOS/i.test(item.name))
131
+ .map((item) => ({ identifier: item.identifier, name: item.name, version: item.version }))
132
+ }
133
+
134
+ async create({ name, deviceType, runtime }) {
135
+ if (!name || !deviceType || !runtime) throw fail('ios-create-bad-request', 'name / deviceType / runtime 都必填')
136
+ const { stdout } = await this.simctl(['create', String(name), String(deviceType), String(runtime)])
137
+ const udid = stdout.toString('utf8').trim()
138
+ if (!udid) throw fail('ios-create-failed', 'simctl create 没有返回 UDID')
139
+ await this.boot(udid)
140
+ return { serial: udid, name }
141
+ }
142
+
143
+ async boot(udid) {
144
+ try {
145
+ await this.simctl(['boot', udid], { timeout: 60000 })
146
+ } catch (error) {
147
+ const message = String(error.message || '')
148
+ if (!/already booted|current state: Booted/i.test(message)) throw error
149
+ }
150
+ return true
151
+ }
152
+
153
+ async shutdown(udid) {
154
+ await this.simctl(['shutdown', udid], { timeout: 30000 })
155
+ return true
156
+ }
157
+
158
+ async remove(udid) {
159
+ await this.simctl(['delete', udid], { timeout: 30000 })
160
+ return true
161
+ }
162
+
163
+ async shot(udid) {
164
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-phone-ios-'))
165
+ const file = join(dir, 'shot.png')
166
+ try {
167
+ await this.simctl(['io', udid, 'screenshot', file], { timeout: SHOT_TIMEOUT })
168
+ const buffer = await readFile(file)
169
+ if (!buffer.length) throw fail('ios-shot-failed', '模拟器截图是空的')
170
+ return buffer
171
+ } finally {
172
+ await rm(dir, { recursive: true, force: true })
173
+ }
174
+ }
175
+
176
+ async screenInfo(udid) {
177
+ const cached = this._screens.get(udid)
178
+ const now = Date.now()
179
+ if (cached && now - cached.at < 60000) return cached.info
180
+ const buffer = await this.shot(udid)
181
+ const size = pngSize(buffer)
182
+ if (!size) throw fail('ios-screen-info-failed', '无法读取模拟器截图尺寸')
183
+ const scale = await this.scale(udid)
184
+ const info = { width: size.width, height: size.height, scale, at: now }
185
+ this._screens.set(udid, { at: now, info })
186
+ return info
187
+ }
188
+
189
+ /** idb describe 里拿 point 尺寸/scale;没有 idb 时返回 null。 */
190
+ async scale(udid) {
191
+ if (!(await this.idbAvailable())) return null
192
+ const cached = this._scales && this._scales.get(udid)
193
+ if (cached && Date.now() - cached.at < 60000) return cached.value
194
+ try {
195
+ const { stdout } = await run(this.idbBin, ['describe', '--udid', udid], { timeout: 10000 })
196
+ const text = stdout.toString('utf8')
197
+ const match = /screen_dimensions=ScreenDimensions\(width=(\d+), height=(\d+), density=([\d.]+)/.exec(text)
198
+ if (match) {
199
+ const value = Number(match[3]) || null
200
+ if (!this._scales) this._scales = new Map()
201
+ this._scales.set(udid, { at: Date.now(), value })
202
+ return value
203
+ }
204
+ } catch {}
205
+ return null
206
+ }
207
+
208
+ async idb(args, options = {}) {
209
+ if (!(await this.idbAvailable())) {
210
+ throw fail('idb-missing', 'iOS 输入/实时画面需要 idb:brew trust --formula facebook/fb/idb-companion && brew install facebook/fb/idb-companion && python3 -m pip install --user fb-idb')
211
+ }
212
+ return run(this.idbBin, args, options)
213
+ }
214
+
215
+ async ping(udid) {
216
+ const started = process.hrtime.bigint()
217
+ try {
218
+ await this.simctl(['list', 'devices', '--json'], { timeout: 5000 })
219
+ } catch {}
220
+ const ms = Number(process.hrtime.bigint() - started) / 1e6
221
+ return { ms: Math.round(ms), at: Date.now() }
222
+ }
223
+
224
+ async input(udid, event = {}) {
225
+ const info = await this.screenInfo(udid)
226
+ const scale = info.scale || 1
227
+ const point = (value) => Math.round(Number(value) / scale)
228
+ const action = String(event.action || '')
229
+ switch (action) {
230
+ case 'tap': {
231
+ await this.idb(['ui', 'tap', '--udid', udid, String(point(event.x)), String(point(event.y))], { timeout: 8000 })
232
+ return { action }
233
+ }
234
+ case 'swipe': {
235
+ await this.idb(
236
+ ['ui', 'swipe', '--udid', udid, String(point(event.x1)), String(point(event.y1)), String(point(event.x2)), String(point(event.y2))],
237
+ { timeout: 10000 },
238
+ )
239
+ return { action }
240
+ }
241
+ case 'key': {
242
+ const key = String(event.key || '')
243
+ if (key === 'home') {
244
+ await this.idb(['ui', 'button', '--udid', udid, 'HOME'], { timeout: 8000 })
245
+ return { action, key }
246
+ }
247
+ if (key === 'back') {
248
+ // iOS 没有返回键:从左边缘右滑(系统返回手势)
249
+ await this.idb(
250
+ ['ui', 'swipe', '--udid', udid, '1', String(Math.round(info.height / scale / 2)), '300', String(Math.round(info.height / scale / 2))],
251
+ { timeout: 8000 },
252
+ )
253
+ return { action, key }
254
+ }
255
+ if (key === 'recents') {
256
+ // 上滑(回到桌面/进入多任务)
257
+ await this.idb(
258
+ [
259
+ 'ui',
260
+ 'swipe',
261
+ '--udid',
262
+ udid,
263
+ String(Math.round(info.width / scale / 2)),
264
+ String(Math.round(info.height / scale - 40)),
265
+ String(Math.round(info.width / scale / 2)),
266
+ String(Math.round(info.height / scale / 2)),
267
+ ],
268
+ { timeout: 8000 },
269
+ )
270
+ return { action, key }
271
+ }
272
+ throw fail('ios-key-unsupported', `iOS 不支持按键: ${key}`)
273
+ }
274
+ case 'rotate': {
275
+ // idb 没有旋转命令,用 simctl 的 io 端口做不了;交给 UI 侧暂不支持
276
+ throw fail('ios-rotate-unsupported', 'iOS 模拟器旋转暂未实现')
277
+ }
278
+ default:
279
+ throw fail('ios-input-unsupported', `iOS 不支持操作: ${action || '(empty)'}`)
280
+ }
281
+ }
282
+
283
+ /** 实时流:idb video-stream 输出 Annex-B H.264,直接复用客户端的 WebCodecs 管线。 */
284
+ async openStream(udid, res, options = {}) {
285
+ if (!(await this.idbAvailable())) {
286
+ throw fail('idb-missing', 'iOS 实时画面需要 idb(brew install facebook/fb/idb-companion + pip install fb-idb)')
287
+ }
288
+ const fps = String(options.fps || 30)
289
+ const args = ['video-stream', '--format', 'h264', '--fps', fps, '--compression-quality', '0.8', '--udid', udid]
290
+ let closed = false
291
+ let child = null
292
+ const start = () => {
293
+ if (closed) return
294
+ child = spawn(this.idbBin, args, { stdio: ['ignore', 'pipe', 'ignore'] })
295
+ if (!child.stdout) {
296
+ closed = true
297
+ return
298
+ }
299
+ child.stdout.on('data', (chunk) => {
300
+ if (closed) return
301
+ if (!res.write(chunk)) {
302
+ child.stdout.pause()
303
+ res.once('drain', () => {
304
+ if (!closed && child && child.stdout) child.stdout.resume()
305
+ })
306
+ }
307
+ })
308
+ child.stdout.on('error', stop)
309
+ child.on('exit', () => {
310
+ if (!closed) setTimeout(start, 300)
311
+ })
312
+ }
313
+ const stop = () => {
314
+ if (closed) return
315
+ closed = true
316
+ if (child) {
317
+ try {
318
+ child.kill('SIGTERM')
319
+ } catch {}
320
+ }
321
+ }
322
+ res.on('close', stop)
323
+ res.on('error', stop)
324
+ start()
325
+ return stop
326
+ }
327
+ }
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-phone",
3
- "version": "0.3.2",
4
- "description": "DSH Web 插件:右侧栏预览并点按控制已连接的 Android 设备(adb)",
3
+ "version": "0.4.0",
4
+ "description": "DSH Web 插件:右侧栏预览并控制 Android / iOS 模拟器 / HarmonyOS 设备与模拟器",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
8
8
  "deepseek-harness",
9
9
  "android",
10
10
  "adb",
11
+ "ios",
12
+ "simulator",
13
+ "harmonyos",
14
+ "hdc",
11
15
  "phone",
12
- "mirror",
13
- "scrcpy"
16
+ "mirror"
14
17
  ],
15
18
  "license": "MIT",
16
19
  "author": "Pony <205503242+jmaubay7@users.noreply.github.com>",
@@ -43,11 +46,12 @@
43
46
  "node": ">=22.5"
44
47
  },
45
48
  "scripts": {
46
- "check": "node --check lib/adb.js && node --check lib/targets.js && node --check lib/stream.js && node --check lib/transport.js && node --check lib/index.js && node --check lib/client.js && node scripts/check-i18n.mjs && node scripts/check-privacy.mjs && node scripts/check-shots.mjs",
49
+ "check": "node --check lib/adb.js && node --check lib/targets.js && node --check lib/stream.js && node --check lib/transport.js && node --check lib/ios.js && node --check lib/hos.js && node --check lib/index.js && node --check lib/client.js && node scripts/check-i18n.mjs && node scripts/check-privacy.mjs && node scripts/check-shots.mjs",
47
50
  "smoke": "node scripts/smoke.mjs",
48
51
  "check-i18n": "node scripts/check-i18n.mjs",
49
52
  "release-notes": "node scripts/release-notes.mjs",
50
- "test-ui": "node scripts/render-test.mjs"
53
+ "test-ui": "node scripts/render-test.mjs",
54
+ "smoke:hos": "node scripts/hos-smoke.mjs"
51
55
  },
52
56
  "dsh": {
53
57
  "manifestVersion": 1,