@ponydev/dsh-phone 0.0.0-stage → 0.2.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 +77 -0
- package/CHANGELOG.md +52 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/RELEASE.md +65 -0
- package/cordis.patch.yml +9 -0
- package/docs/DESIGN.md +248 -0
- package/docs/mockups/device.png +0 -0
- package/docs/mockups/list.png +0 -0
- package/docs/mockups/mockup.html +384 -0
- package/docs/screenshots/01-devices.png +0 -0
- package/docs/screenshots/02-preview.png +0 -0
- package/docs/screenshots/03-overview.png +0 -0
- package/lib/adb.js +400 -0
- package/lib/client.js +1652 -0
- package/lib/index.js +184 -0
- package/lib/stream.js +111 -0
- package/lib/targets.js +55 -0
- package/lib/transport.js +55 -0
- package/package.json +65 -4
package/lib/adb.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// dsh-phone · adb 层
|
|
2
|
+
// 只做三件事:找 adb、跑 adb、把输出解析成结构化数据。
|
|
3
|
+
// 所有命令一律 execFile + 参数数组,不经过 shell;serial / 地址都先过白名单。
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** serial 白名单:字母数字开头,允许 . _ : -,最长 128 */
|
|
10
|
+
export const SERIAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$/
|
|
11
|
+
/** adb connect 地址:host:port,host 只允许字母数字 . - _ : */
|
|
12
|
+
export const ADDRESS_RE = /^[A-Za-z0-9][A-Za-z0-9.\-_:]{0,127}:\d{1,5}$/
|
|
13
|
+
/** `input keyevent` 白名单 */
|
|
14
|
+
export const KEYS = Object.freeze({
|
|
15
|
+
back: '4',
|
|
16
|
+
home: '3',
|
|
17
|
+
recents: '187',
|
|
18
|
+
power: '26',
|
|
19
|
+
wakeup: '224',
|
|
20
|
+
menu: '82',
|
|
21
|
+
enter: '66',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const DEVICE_STATES = new Set([
|
|
25
|
+
'device',
|
|
26
|
+
'offline',
|
|
27
|
+
'unauthorized',
|
|
28
|
+
'authorizing',
|
|
29
|
+
'recovering',
|
|
30
|
+
'bootloader',
|
|
31
|
+
'recovery',
|
|
32
|
+
'sideload',
|
|
33
|
+
'unknown',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TIMEOUT = 8000
|
|
37
|
+
const SHOT_TIMEOUT = 10000
|
|
38
|
+
const DEVICES_TTL = 1500
|
|
39
|
+
const PLATFORM_TTL = 5 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
function fail(code, message) {
|
|
42
|
+
const error = new Error(message)
|
|
43
|
+
error.code = code
|
|
44
|
+
return error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function firstLine(buffer) {
|
|
48
|
+
if (!buffer) return ''
|
|
49
|
+
const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
|
|
50
|
+
return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] || ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function exec(file, args, options = {}) {
|
|
54
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT
|
|
55
|
+
const maxBuffer = options.maxBuffer || 32 * 1024 * 1024
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
execFile(file, args, { timeout, maxBuffer, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => {
|
|
58
|
+
if (error) {
|
|
59
|
+
const message = firstLine(stderr) || firstLine(stdout) || error.message || 'adb failed'
|
|
60
|
+
const failure = fail(options.code || 'adb-failed', message)
|
|
61
|
+
failure.stdout = stdout
|
|
62
|
+
failure.stderr = stderr
|
|
63
|
+
failure.exitCode = typeof error.code === 'number' ? error.code : undefined
|
|
64
|
+
failure.timedOut = error.killed === true
|
|
65
|
+
reject(failure)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
resolve({ stdout, stderr })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function prettyModel(value) {
|
|
74
|
+
if (!value) return ''
|
|
75
|
+
return String(value).replace(/_/g, ' ').trim()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Tailscale 的 100.64.0.0/10(RFC 6598 CGNAT) */
|
|
79
|
+
function isCgnatSerial(serial) {
|
|
80
|
+
const match = /^(\d+)\.(\d+)\.\d+\.\d+:/.exec(serial)
|
|
81
|
+
if (!match) return false
|
|
82
|
+
const first = Number(match[1])
|
|
83
|
+
const second = Number(match[2])
|
|
84
|
+
return first === 100 && second >= 64 && second <= 127
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 按 serial / adb 属性给设备标注连接方式,客户端只显示标签,不做分组。 */
|
|
88
|
+
export function classifyDevice(serial, props = {}) {
|
|
89
|
+
if (props.usb) return 'usb'
|
|
90
|
+
if (/^emulator-\d+$/.test(serial)) return 'emulator'
|
|
91
|
+
if (/^unicom-openclaw-|\.ts\.net/.test(serial)) return 'tailscale'
|
|
92
|
+
if (isCgnatSerial(serial)) return 'tailscale'
|
|
93
|
+
if (serial.includes(':')) return 'wifi'
|
|
94
|
+
return 'usb'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 解析 `adb devices -l`。纯函数,smoke 直接测它。 */
|
|
98
|
+
export function parseDevices(text) {
|
|
99
|
+
const devices = []
|
|
100
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
101
|
+
const line = raw.trim()
|
|
102
|
+
if (!line) continue
|
|
103
|
+
if (line.startsWith('List of devices')) continue
|
|
104
|
+
if (line.startsWith('*') || line.startsWith('adb server') || line.startsWith('ADB server')) continue
|
|
105
|
+
const parts = line.split(/\s+/)
|
|
106
|
+
if (parts.length < 2) continue
|
|
107
|
+
const serial = parts[0]
|
|
108
|
+
const state = parts[1]
|
|
109
|
+
if (!SERIAL_RE.test(serial)) continue
|
|
110
|
+
if (!DEVICE_STATES.has(state)) continue
|
|
111
|
+
const props = {}
|
|
112
|
+
for (const pair of parts.slice(2)) {
|
|
113
|
+
const at = pair.indexOf(':')
|
|
114
|
+
if (at > 0) props[pair.slice(0, at)] = pair.slice(at + 1)
|
|
115
|
+
}
|
|
116
|
+
devices.push({
|
|
117
|
+
serial,
|
|
118
|
+
state,
|
|
119
|
+
kind: classifyDevice(serial, props),
|
|
120
|
+
model: prettyModel(props.model) || prettyModel(props.product) || serial,
|
|
121
|
+
product: props.product || null,
|
|
122
|
+
device: props.device || null,
|
|
123
|
+
transportId: props.transport_id || null,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
return devices
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 解析 `adb shell wm size`:优先 Override size(实际显示分辨率),否则 Physical size。 */
|
|
130
|
+
export function parseWmSize(text) {
|
|
131
|
+
const source = String(text || '')
|
|
132
|
+
const override = /Override size:\s*(\d{2,5})x(\d{2,5})/i.exec(source)
|
|
133
|
+
const physical = /Physical size:\s*(\d{2,5})x(\d{2,5})/i.exec(source)
|
|
134
|
+
const match = override || physical
|
|
135
|
+
if (!match) return null
|
|
136
|
+
return { width: Number(match[1]), height: Number(match[2]), overridden: Boolean(override) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 解析 `adb shell getprop` 的 `[key]: [value]` 输出 */
|
|
140
|
+
export function parseGetprop(text) {
|
|
141
|
+
const props = {}
|
|
142
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
143
|
+
const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim())
|
|
144
|
+
if (match) props[match[1]] = match[2]
|
|
145
|
+
}
|
|
146
|
+
return props
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 从 getprop 属性判断系统;没有 harmony 标记的一律当 Android。 */
|
|
150
|
+
export function platformFromProps(props) {
|
|
151
|
+
const brand = String(props['ro.product.brand'] || props['ro.product.manufacturer'] || '')
|
|
152
|
+
const harmonyKeys = ['ro.build.version.harmony', 'ro.build.version.harmonyos', 'hw_sc.build.platform.version']
|
|
153
|
+
for (const key of harmonyKeys) {
|
|
154
|
+
const value = props[key]
|
|
155
|
+
if (typeof value === 'string' && value.trim() && value.trim() !== 'unknown') return 'harmony'
|
|
156
|
+
}
|
|
157
|
+
if (/harmony/i.test(brand)) return 'harmony'
|
|
158
|
+
return 'android'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseVersion(buffer) {
|
|
162
|
+
const text = buffer ? buffer.toString('utf8') : ''
|
|
163
|
+
const match = /version\s+([0-9][0-9A-Za-z.\-]*)/.exec(text)
|
|
164
|
+
return match ? match[1] : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** adb 进程封装。一个实例同时服务所有设备,按 serial 串行化写操作。 */
|
|
168
|
+
export class Adb {
|
|
169
|
+
constructor(options = {}) {
|
|
170
|
+
this.configuredPath = typeof options.path === 'string' && options.path.trim() ? options.path.trim() : 'adb'
|
|
171
|
+
this.timeout = Number(options.timeout) > 0 ? Number(options.timeout) : DEFAULT_TIMEOUT
|
|
172
|
+
this.bin = null
|
|
173
|
+
this.info = null
|
|
174
|
+
this._resolving = null
|
|
175
|
+
this._devices = { at: 0, list: [] }
|
|
176
|
+
this._locks = new Map()
|
|
177
|
+
this._screens = new Map()
|
|
178
|
+
this._platforms = new Map()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 找到可用的 adb 可执行文件(配置值 → PATH → SDK 常见位置)。 */
|
|
182
|
+
async resolve() {
|
|
183
|
+
if (this.info) return this.info
|
|
184
|
+
if (this._resolving) return this._resolving
|
|
185
|
+
this._resolving = (async () => {
|
|
186
|
+
const candidates = []
|
|
187
|
+
if (this.configuredPath !== 'adb') candidates.push(this.configuredPath)
|
|
188
|
+
candidates.push('adb')
|
|
189
|
+
for (const env of ['ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_SDK']) {
|
|
190
|
+
const value = process.env[env]
|
|
191
|
+
if (value) candidates.push(join(value, 'platform-tools', 'adb'))
|
|
192
|
+
}
|
|
193
|
+
candidates.push(join(homedir(), 'Library', 'Android', 'sdk', 'platform-tools', 'adb'))
|
|
194
|
+
candidates.push('/opt/homebrew/bin/adb', '/usr/local/bin/adb')
|
|
195
|
+
const seen = new Set()
|
|
196
|
+
for (const candidate of candidates) {
|
|
197
|
+
if (seen.has(candidate)) continue
|
|
198
|
+
seen.add(candidate)
|
|
199
|
+
if (candidate.includes('/') && !existsSync(candidate)) continue
|
|
200
|
+
try {
|
|
201
|
+
const { stdout } = await exec(candidate, ['version'], { timeout: 5000 })
|
|
202
|
+
this.bin = candidate
|
|
203
|
+
this.info = {
|
|
204
|
+
bin: candidate,
|
|
205
|
+
version: parseVersion(stdout),
|
|
206
|
+
raw: stdout.toString('utf8').split(/\r?\n/).filter(Boolean).slice(0, 2),
|
|
207
|
+
}
|
|
208
|
+
return this.info
|
|
209
|
+
} catch {
|
|
210
|
+
// 继续找下一个
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw fail(
|
|
214
|
+
'adb-missing',
|
|
215
|
+
'未找到 adb。安装 Android platform-tools,或在 cordis.patch.yml 里把 adbPath 指向 adb 可执行文件。',
|
|
216
|
+
)
|
|
217
|
+
})()
|
|
218
|
+
try {
|
|
219
|
+
return await this._resolving
|
|
220
|
+
} finally {
|
|
221
|
+
this._resolving = null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async _run(args, options = {}) {
|
|
226
|
+
await this.resolve()
|
|
227
|
+
return exec(this.bin, args, options)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `adb devices -l`,1.5s 内复用缓存。 */
|
|
231
|
+
async devices({ fresh = false } = {}) {
|
|
232
|
+
const now = Date.now()
|
|
233
|
+
if (!fresh && now - this._devices.at < DEVICES_TTL) {
|
|
234
|
+
return { devices: this._devices.list, at: this._devices.at }
|
|
235
|
+
}
|
|
236
|
+
const { stdout } = await this._run(['devices', '-l'], { timeout: 5000 })
|
|
237
|
+
const list = parseDevices(stdout.toString('utf8'))
|
|
238
|
+
await Promise.all(
|
|
239
|
+
list.map(async (device) => {
|
|
240
|
+
if (device.state !== 'device') {
|
|
241
|
+
device.platform = null
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
device.platform = await Promise.race([
|
|
246
|
+
this.platform(device.serial),
|
|
247
|
+
new Promise((resolve) => setTimeout(() => resolve('android'), 1500)),
|
|
248
|
+
])
|
|
249
|
+
} catch {
|
|
250
|
+
device.platform = 'android'
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
)
|
|
254
|
+
this._devices = { at: now, list }
|
|
255
|
+
return { devices: list, at: now }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 设备必须出现在最新列表里且处于 device 状态,才允许执行后续操作。 */
|
|
259
|
+
async assertReady(serial) {
|
|
260
|
+
if (typeof serial !== 'string' || !SERIAL_RE.test(serial)) {
|
|
261
|
+
throw fail('bad-serial', '设备序列号不合法')
|
|
262
|
+
}
|
|
263
|
+
const { devices } = await this.devices({ fresh: true })
|
|
264
|
+
const device = devices.find((item) => item.serial === serial)
|
|
265
|
+
if (!device) throw fail('unknown-device', `设备 ${serial} 不在 adb devices 列表里`)
|
|
266
|
+
if (device.state === 'unauthorized') {
|
|
267
|
+
throw fail('device-unauthorized', `设备 ${device.model} 未授权:请在手机上点「允许 USB 调试」`)
|
|
268
|
+
}
|
|
269
|
+
if (device.state !== 'device') {
|
|
270
|
+
throw fail('device-not-ready', `设备 ${device.model} 当前状态是 ${device.state}`)
|
|
271
|
+
}
|
|
272
|
+
return device
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 同一台设备的写操作串行执行,避免 input 乱序。 */
|
|
276
|
+
async _locked(serial, task) {
|
|
277
|
+
const previous = this._locks.get(serial) || Promise.resolve()
|
|
278
|
+
const current = previous.then(task, task)
|
|
279
|
+
const guarded = current.catch(() => {})
|
|
280
|
+
this._locks.set(serial, guarded)
|
|
281
|
+
try {
|
|
282
|
+
return await current
|
|
283
|
+
} finally {
|
|
284
|
+
if (this._locks.get(serial) === guarded) this._locks.delete(serial)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 系统平台(android / harmony),getprop 读一次缓存 5 分钟。 */
|
|
289
|
+
async platform(serial) {
|
|
290
|
+
const cached = this._platforms.get(serial)
|
|
291
|
+
const now = Date.now()
|
|
292
|
+
if (cached && now - cached.at < PLATFORM_TTL) return cached.platform
|
|
293
|
+
const { stdout } = await this._run(['-s', serial, 'shell', 'getprop'], {
|
|
294
|
+
timeout: 4000,
|
|
295
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
296
|
+
})
|
|
297
|
+
const platform = platformFromProps(parseGetprop(stdout.toString('utf8')))
|
|
298
|
+
this._platforms.set(serial, { at: now, platform })
|
|
299
|
+
return platform
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 设备物理分辨率(wm size),客户端用它把画面坐标换算成输入坐标;缓存 60s。 */
|
|
303
|
+
async screenInfo(serial) {
|
|
304
|
+
await this.assertReady(serial)
|
|
305
|
+
const cached = this._screens.get(serial)
|
|
306
|
+
const now = Date.now()
|
|
307
|
+
if (cached && now - cached.at < 60_000) return cached.info
|
|
308
|
+
const { stdout } = await this._run(['-s', serial, 'shell', 'wm', 'size'], { timeout: 5000 })
|
|
309
|
+
const info = parseWmSize(stdout.toString('utf8'))
|
|
310
|
+
if (!info) throw fail('bad-screen-info', '无法读取设备分辨率')
|
|
311
|
+
this._screens.set(serial, { at: now, info })
|
|
312
|
+
return info
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async connect(address) {
|
|
316
|
+
if (typeof address !== 'string' || !ADDRESS_RE.test(address.trim())) {
|
|
317
|
+
throw fail('bad-address', '地址格式应为 host:port,例如 100.90.248.53:5555')
|
|
318
|
+
}
|
|
319
|
+
const { stdout } = await this._run(['connect', address.trim()], { timeout: 8000 })
|
|
320
|
+
this._devices.at = 0
|
|
321
|
+
return { address: address.trim(), output: stdout.toString('utf8').trim() }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async disconnect(serial) {
|
|
325
|
+
if (typeof serial !== 'string' || !SERIAL_RE.test(serial)) throw fail('bad-serial', '设备序列号不合法')
|
|
326
|
+
const { stdout } = await this._run(['disconnect', serial], { timeout: 8000 })
|
|
327
|
+
this._devices.at = 0
|
|
328
|
+
return { serial, output: stdout.toString('utf8').trim() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 截一帧 PNG(原始字节)。 */
|
|
332
|
+
async shot(serial) {
|
|
333
|
+
await this.assertReady(serial)
|
|
334
|
+
const { stdout } = await this._run(['-s', serial, 'exec-out', 'screencap', '-p'], {
|
|
335
|
+
timeout: SHOT_TIMEOUT,
|
|
336
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
337
|
+
})
|
|
338
|
+
if (stdout.length < 8 || stdout[0] !== 0x89 || stdout.toString('ascii', 1, 4) !== 'PNG') {
|
|
339
|
+
throw fail('bad-screenshot', '设备返回的不是 PNG 截图')
|
|
340
|
+
}
|
|
341
|
+
return stdout
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 点按 / 滑动 / 按键 / 旋转。坐标是设备像素。 */
|
|
345
|
+
async input(serial, event = {}) {
|
|
346
|
+
await this.assertReady(serial)
|
|
347
|
+
const action = String(event.action || '')
|
|
348
|
+
return this._locked(serial, async () => {
|
|
349
|
+
switch (action) {
|
|
350
|
+
case 'tap': {
|
|
351
|
+
const x = clampInt(event.x, 0, 20000)
|
|
352
|
+
const y = clampInt(event.y, 0, 20000)
|
|
353
|
+
await this._run(['-s', serial, 'shell', 'input', 'tap', String(x), String(y)], { timeout: 5000 })
|
|
354
|
+
return { action, x, y }
|
|
355
|
+
}
|
|
356
|
+
case 'swipe': {
|
|
357
|
+
const x1 = clampInt(event.x1, 0, 20000)
|
|
358
|
+
const y1 = clampInt(event.y1, 0, 20000)
|
|
359
|
+
const x2 = clampInt(event.x2, 0, 20000)
|
|
360
|
+
const y2 = clampInt(event.y2, 0, 20000)
|
|
361
|
+
const duration = clampInt(event.duration, 50, 5000)
|
|
362
|
+
await this._run(
|
|
363
|
+
['-s', serial, 'shell', 'input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(duration)],
|
|
364
|
+
{ timeout: 8000 },
|
|
365
|
+
)
|
|
366
|
+
return { action, x1, y1, x2, y2, duration }
|
|
367
|
+
}
|
|
368
|
+
case 'key': {
|
|
369
|
+
const key = String(event.key || '')
|
|
370
|
+
const code = KEYS[key]
|
|
371
|
+
if (!code) throw fail('bad-key', `不支持的按键: ${key}`)
|
|
372
|
+
await this._run(['-s', serial, 'shell', 'input', 'keyevent', code], { timeout: 5000 })
|
|
373
|
+
return { action, key }
|
|
374
|
+
}
|
|
375
|
+
case 'rotate': {
|
|
376
|
+
const { stdout } = await this._run(['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation'], {
|
|
377
|
+
timeout: 5000,
|
|
378
|
+
})
|
|
379
|
+
const current = Number(stdout.toString('utf8').trim()) || 0
|
|
380
|
+
const next = (current + 1) % 4
|
|
381
|
+
await this._run(['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0'], {
|
|
382
|
+
timeout: 5000,
|
|
383
|
+
})
|
|
384
|
+
await this._run(['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)], {
|
|
385
|
+
timeout: 5000,
|
|
386
|
+
})
|
|
387
|
+
return { action, rotation: next }
|
|
388
|
+
}
|
|
389
|
+
default:
|
|
390
|
+
throw fail('bad-action', `不支持的操作: ${action || '(empty)'}`)
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function clampInt(value, min, max) {
|
|
397
|
+
const number = Math.round(Number(value))
|
|
398
|
+
if (!Number.isFinite(number)) throw fail('bad-coordinate', '坐标必须是数字')
|
|
399
|
+
return Math.min(max, Math.max(min, number))
|
|
400
|
+
}
|