@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/AGENTS.md +4 -1
- package/CHANGELOG.md +15 -0
- package/README.md +23 -8
- package/cordis.patch.yml +4 -0
- package/docs/DESIGN.md +20 -0
- package/lib/client.js +361 -3
- package/lib/hos.js +1144 -0
- package/lib/index.js +171 -13
- package/lib/ios.js +327 -0
- package/package.json +10 -6
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
|
-
|
|
76
|
-
const { devices } = await adb.devices()
|
|
77
|
-
return ok({ adb: info, devices: devices.length })
|
|
121
|
+
adbInfo = await adb.resolve()
|
|
78
122
|
} catch (error) {
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "DSH Web
|
|
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,
|