@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/lib/index.js ADDED
@@ -0,0 +1,184 @@
1
+ // dsh-phone · Host 半区
2
+ // - 挂 /phone 前缀路由(webServer.register + connection.admit 认证围栏)
3
+ // - 纯 JSON 协议:POST /phone/<endpoint> → { ok: true, value } | { ok: false, error }
4
+ // - 截图例外:成功时直接回 image/png 原始字节
5
+ // - 不暴露任意 adb shell;serial 必须在最新 adb devices 列表里
6
+ import { join } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { createTransports } from './transport.js'
9
+ import { createTargetStore } from './targets.js'
10
+ import { STREAM_QUALITY, createStreamer, streamSizeFor } from './stream.js'
11
+
12
+ export const name = 'dsh-phone'
13
+
14
+ const DEFAULTS = {
15
+ adbPath: 'adb',
16
+ pollMs: 700,
17
+ streamTimeLimit: 180,
18
+ streamBitRate: 6_000_000,
19
+ streamSize: '540x1200',
20
+ }
21
+
22
+ function messageOf(error) {
23
+ const text = error && error.message ? String(error.message) : '未知错误'
24
+ return text.length > 400 ? text.slice(0, 400) : text
25
+ }
26
+
27
+ function failOf(error) {
28
+ const code = error && typeof error.code === 'string' ? error.code : 'phone/error'
29
+ return { ok: false, error: { code, message: messageOf(error) } }
30
+ }
31
+
32
+ function readBody(req, cap = 1024 * 1024) {
33
+ return new Promise((resolve, reject) => {
34
+ const chunks = []
35
+ let size = 0
36
+ req.on('data', (chunk) => {
37
+ size += chunk.length
38
+ if (size > cap) {
39
+ reject(new Error('request body too large'))
40
+ req.destroy()
41
+ return
42
+ }
43
+ chunks.push(chunk)
44
+ })
45
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
46
+ req.on('error', reject)
47
+ })
48
+ }
49
+
50
+ function json(res, code, payload) {
51
+ res.writeHead(code, {
52
+ 'content-type': 'application/json; charset=utf-8',
53
+ 'cache-control': 'no-store',
54
+ })
55
+ res.end(JSON.stringify(payload))
56
+ }
57
+
58
+ export function apply(ctx, rawConfig) {
59
+ const config = { ...DEFAULTS, ...(rawConfig || {}) }
60
+ const { adb } = createTransports({ adbPath: config.adbPath })
61
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() ? process.env.DSH_HOME.trim() : join(homedir(), '.dsh')
62
+ const targets = createTargetStore(join(home, 'phone', 'targets.json'))
63
+ const streamer = createStreamer(adb, {
64
+ timeLimit: config.streamTimeLimit,
65
+ bitRate: config.streamBitRate,
66
+ size: config.streamSize,
67
+ })
68
+
69
+ const ok = (value) => ({ ok: true, value })
70
+
71
+ async function handle(endpoint, payload) {
72
+ switch (endpoint) {
73
+ case 'health': {
74
+ try {
75
+ const info = await adb.resolve()
76
+ const { devices } = await adb.devices()
77
+ return ok({ adb: info, devices: devices.length })
78
+ } catch (error) {
79
+ return ok({ adb: null, devices: 0, error: messageOf(error) })
80
+ }
81
+ }
82
+ case 'devices': {
83
+ const { devices, at } = await adb.devices({ fresh: true })
84
+ return ok({ devices, at })
85
+ }
86
+ case 'connect': {
87
+ const result = await adb.connect(String(payload.address || ''))
88
+ await targets.record(result.address)
89
+ return ok(result)
90
+ }
91
+ case 'targets':
92
+ return ok({ targets: await targets.list() })
93
+ case 'targets/remove':
94
+ return ok({ removed: await targets.remove(String(payload.address || '')) })
95
+ case 'screen-info':
96
+ return ok(await adb.screenInfo(String(payload.serial || '')))
97
+ case 'disconnect':
98
+ return ok(await adb.disconnect(String(payload.serial || '')))
99
+ case 'input':
100
+ return ok(await adb.input(String(payload.serial || ''), payload.event || {}))
101
+ default:
102
+ return { ok: false, error: { code: 'phone/not-found', message: `未知接口: ${endpoint || '(empty)'}` } }
103
+ }
104
+ }
105
+
106
+ ctx.inject(['connection', 'webServer'], (scope) => {
107
+ scope.effect(
108
+ () =>
109
+ scope.webServer.register({
110
+ kind: 'prefix',
111
+ path: '/phone',
112
+ handler: async (req, res) => {
113
+ const admission = scope.connection.admit(req)
114
+ if ('rejection' in admission) {
115
+ res.writeHead(admission.rejection)
116
+ res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
117
+ return
118
+ }
119
+ const pathname = String(req.url || '/').split('?')[0]
120
+ const endpoint = pathname.replace(/^\/phone\/?/, '').replace(/\/+$/, '')
121
+ if (req.method !== 'POST') {
122
+ json(res, 405, { ok: false, error: { code: 'phone/method', message: '只接受 POST' } })
123
+ return
124
+ }
125
+ let payload = {}
126
+ try {
127
+ payload = JSON.parse((await readBody(req)) || '{}')
128
+ } catch {
129
+ json(res, 400, { ok: false, error: { code: 'phone/bad-body', message: '请求体不是 JSON' } })
130
+ return
131
+ }
132
+ if (endpoint === 'stream') {
133
+ const serial = String(payload.serial || '')
134
+ try {
135
+ await adb.assertReady(serial)
136
+ } catch (error) {
137
+ json(res, 200, failOf(error))
138
+ return
139
+ }
140
+ res.writeHead(200, {
141
+ 'content-type': 'video/h264',
142
+ 'cache-control': 'no-store',
143
+ 'x-content-type-options': 'nosniff',
144
+ 'x-accel-buffering': 'no',
145
+ })
146
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
147
+ const preset = STREAM_QUALITY[String(payload.quality || '')]
148
+ let openOptions = {}
149
+ if (preset) {
150
+ openOptions = { bitRate: preset.bitRate }
151
+ try {
152
+ const info = await adb.screenInfo(serial)
153
+ const dims = streamSizeFor(info, preset.shortSide)
154
+ if (dims) openOptions.size = dims.width + 'x' + dims.height
155
+ } catch {}
156
+ }
157
+ streamer.open(serial, res, openOptions)
158
+ return
159
+ }
160
+ if (endpoint === 'shot') {
161
+ try {
162
+ const buffer = await adb.shot(String(payload.serial || ''))
163
+ res.writeHead(200, {
164
+ 'content-type': 'image/png',
165
+ 'content-length': buffer.length,
166
+ 'cache-control': 'no-store',
167
+ })
168
+ res.end(buffer)
169
+ } catch (error) {
170
+ json(res, 200, failOf(error))
171
+ }
172
+ return
173
+ }
174
+ try {
175
+ json(res, 200, await handle(endpoint, payload))
176
+ } catch (error) {
177
+ json(res, 200, failOf(error))
178
+ }
179
+ },
180
+ }),
181
+ 'dsh-phone: route',
182
+ )
183
+ })
184
+ }
package/lib/stream.js ADDED
@@ -0,0 +1,111 @@
1
+ // dsh-phone · H.264 流(P1)
2
+ // `adb exec-out screenrecord --output-format=h264 -` 是 Annex-B 裸流,
3
+ // 这里只负责把子进程 stdout 原样 pipe 给 HTTP 响应,并管理生命周期:
4
+ // - 客户端断开 → kill 子进程
5
+ // - screenrecord 单次上限(默认 180s)→ 自动重启,客户端在下个 IDR 恢复
6
+ // - res.write 背压 → pause/resume 子进程 stdout
7
+ import { spawn } from 'node:child_process'
8
+
9
+ /** 清晰度预设:按设备实际显示的短边缩放,保持屏幕比例,不改手机分辨率。 */
10
+ export const STREAM_QUALITY = Object.freeze({
11
+ low: { shortSide: 360, bitRate: 2_000_000 },
12
+ medium: { shortSide: 540, bitRate: 6_000_000 },
13
+ high: { shortSide: 720, bitRate: 10_000_000 },
14
+ })
15
+
16
+ /**
17
+ * 按设备分辨率算出 screenrecord 的输出尺寸:等比缩放到目标短边、宽高取偶数。
18
+ * 例如 Pixel 1080x2400 + 标准(540) => 540x1200;720x1280 => 540x960。
19
+ */
20
+ export function streamSizeFor(info, shortSide) {
21
+ const width = Number(info && info.width) || 0
22
+ const height = Number(info && info.height) || 0
23
+ if (width < 2 || height < 2) return null
24
+ const short = Math.min(width, height)
25
+ const scale = Math.min(1, shortSide / short)
26
+ const even = (value, max) => {
27
+ const rounded = Math.max(2, Math.round((value * scale) / 2) * 2)
28
+ return Math.min(Math.floor(max / 2) * 2, rounded)
29
+ }
30
+ return { width: even(width, width), height: even(height, height) }
31
+ }
32
+
33
+ const DEFAULTS = {
34
+ timeLimit: 180,
35
+ bitRate: 6_000_000,
36
+ size: '540x1200',
37
+ restartDelayMs: 250,
38
+ }
39
+
40
+ export function createStreamer(adb, options = {}) {
41
+ const config = { ...DEFAULTS, ...options }
42
+
43
+ return {
44
+ /**
45
+ * 把某台设备的 H.264 流接到一个 HTTP 响应上。
46
+ * 调用前必须先 adb.assertReady(serial),这里不再重复校验。
47
+ */
48
+ open(serial, res, overrides = {}) {
49
+ const size = overrides.size || config.size
50
+ const bitRate = overrides.bitRate || config.bitRate
51
+ let closed = false
52
+ let child = null
53
+ let restartTimer = null
54
+
55
+ const stop = () => {
56
+ if (closed) return
57
+ closed = true
58
+ if (restartTimer) clearTimeout(restartTimer)
59
+ if (child) {
60
+ try {
61
+ child.kill('SIGTERM')
62
+ } catch {}
63
+ }
64
+ }
65
+
66
+ const start = () => {
67
+ if (closed) return
68
+ const args = [
69
+ '-s',
70
+ serial,
71
+ 'exec-out',
72
+ 'screenrecord',
73
+ '--output-format=h264',
74
+ '--time-limit=' + String(config.timeLimit),
75
+ '--bit-rate=' + String(bitRate),
76
+ '--size=' + size,
77
+ '-',
78
+ ]
79
+ child = spawn(adb.bin, args, { stdio: ['ignore', 'pipe', 'ignore'] })
80
+ if (!child.stdout) {
81
+ stop()
82
+ return
83
+ }
84
+ child.stdout.on('data', (chunk) => {
85
+ if (closed) return
86
+ const ok = res.write(chunk)
87
+ if (!ok) {
88
+ child.stdout.pause()
89
+ res.once('drain', () => {
90
+ if (!closed && child && child.stdout) child.stdout.resume()
91
+ })
92
+ }
93
+ })
94
+ child.stdout.on('error', stop)
95
+ child.on('error', () => {
96
+ if (closed) return
97
+ restartTimer = setTimeout(start, 1000)
98
+ })
99
+ child.on('exit', () => {
100
+ if (closed) return
101
+ restartTimer = setTimeout(start, config.restartDelayMs)
102
+ })
103
+ }
104
+
105
+ res.on('close', stop)
106
+ res.on('error', stop)
107
+ start()
108
+ return stop
109
+ },
110
+ }
111
+ }
package/lib/targets.js ADDED
@@ -0,0 +1,55 @@
1
+ // dsh-phone · 连接目标收藏($DSH_HOME/phone/targets.json)
2
+ // 只存地址和最近使用时间,不存任何凭据;文件权限 600。
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { dirname } from 'node:path'
5
+ import { ADDRESS_RE } from './adb.js'
6
+
7
+ const LIMIT = 12
8
+
9
+ export function createTargetStore(file) {
10
+ let cache = null
11
+
12
+ async function load() {
13
+ if (cache !== null) return cache
14
+ try {
15
+ const parsed = JSON.parse(await readFile(file, 'utf8'))
16
+ const raw = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.targets) ? parsed.targets : []
17
+ cache = raw.filter((item) => item && typeof item.address === 'string' && ADDRESS_RE.test(item.address))
18
+ } catch {
19
+ cache = []
20
+ }
21
+ return cache
22
+ }
23
+
24
+ async function persist(list) {
25
+ cache = list.slice(0, LIMIT)
26
+ await mkdir(dirname(file), { recursive: true })
27
+ await writeFile(file, JSON.stringify({ version: 1, targets: cache }, null, 2) + '\n', { mode: 0o600 })
28
+ }
29
+
30
+ return {
31
+ async list() {
32
+ return [...(await load())]
33
+ },
34
+ /** 连接成功后记录最近使用(去重、置顶、最多 12 条)。 */
35
+ async record(address) {
36
+ if (typeof address !== 'string' || !ADDRESS_RE.test(address)) return null
37
+ const list = await load()
38
+ const index = list.findIndex((item) => item.address === address)
39
+ const entry = {
40
+ address,
41
+ lastSeenAt: Date.now(),
42
+ pinned: index >= 0 ? list[index].pinned === true : false,
43
+ }
44
+ if (index >= 0) list.splice(index, 1)
45
+ list.unshift(entry)
46
+ await persist(list)
47
+ return entry
48
+ },
49
+ async remove(address) {
50
+ const list = await load()
51
+ await persist(list.filter((item) => item.address !== address))
52
+ return true
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ // dsh-phone · transport 层
2
+ // 把「设备从哪来、画面怎么取、输入怎么发」抽成一个接口。
3
+ // 现在只有 adb;以后加 hdc(鸿蒙)或 iOS(idb / WDA)时,各实现一个 transport,UI 不变。
4
+ //
5
+ // interface PhoneTransport {
6
+ // id: 'adb' | 'hdc' | 'ios'
7
+ // label: string
8
+ // resolve(): Promise<unknown>
9
+ // list(): Promise<Device[]>
10
+ // connect(target: string): Promise<unknown>
11
+ // disconnect(serial: string): Promise<unknown>
12
+ // shot(serial: string): Promise<Buffer>
13
+ // input(serial: string, event: object): Promise<unknown>
14
+ // }
15
+ import { Adb } from './adb.js'
16
+
17
+ export class AdbTransport {
18
+ constructor(adb) {
19
+ this.id = 'adb'
20
+ this.label = 'adb'
21
+ this.adb = adb
22
+ }
23
+
24
+ resolve() {
25
+ return this.adb.resolve()
26
+ }
27
+
28
+ async list() {
29
+ const { devices } = await this.adb.devices()
30
+ return devices.map((device) => ({ ...device, transport: this.id }))
31
+ }
32
+
33
+ connect(address) {
34
+ return this.adb.connect(address)
35
+ }
36
+
37
+ disconnect(serial) {
38
+ return this.adb.disconnect(serial)
39
+ }
40
+
41
+ shot(serial) {
42
+ return this.adb.shot(serial)
43
+ }
44
+
45
+ input(serial, event) {
46
+ return this.adb.input(serial, event)
47
+ }
48
+ }
49
+
50
+ export function createTransports(options = {}) {
51
+ const adb = new Adb({ path: options.adbPath })
52
+ const transports = new Map()
53
+ transports.set('adb', new AdbTransport(adb))
54
+ return { adb, transports }
55
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-phone",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "DSH Web 插件:右侧栏预览并点按控制已连接的 Android 设备(adb)",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "android",
10
+ "adb",
11
+ "phone",
12
+ "mirror",
13
+ "scrcpy"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Pony <205503242+jmaubay7@users.noreply.github.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/PonyDevAI/dsh-phone.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/PonyDevAI/dsh-phone/issues"
23
+ },
24
+ "homepage": "https://github.com/PonyDevAI/dsh-phone#readme",
25
+ "type": "module",
26
+ "files": [
27
+ "lib",
28
+ "docs",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE",
33
+ "AGENTS.md",
34
+ "CONTRIBUTING.md",
35
+ "RELEASE.md"
36
+ ],
37
+ "exports": {
38
+ ".": "./lib/index.js",
39
+ "./client": "./lib/client.js"
40
+ },
41
+ "engines": {
42
+ "dsh": ">=0.2.0-rc.2 <0.3.0",
43
+ "node": ">=22.5"
44
+ },
45
+ "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",
47
+ "smoke": "node scripts/smoke.mjs",
48
+ "check-i18n": "node scripts/check-i18n.mjs",
49
+ "release-notes": "node scripts/release-notes.mjs",
50
+ "test-ui": "node scripts/render-test.mjs"
51
+ },
52
+ "dsh": {
53
+ "manifestVersion": 1,
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web",
59
+ "immediately": true,
60
+ "inject": [
61
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
62
+ "@deepseek-ai/dsh-client-ui-conversation",
63
+ "@deepseek-ai/dsh-client-locale"
64
+ ]
65
+ }
66
+ }
67
+ }