dsh-mobilecode 0.1.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/LICENSE +26 -0
- package/README.md +164 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +828 -0
- package/lib/device-build.js +964 -0
- package/lib/device-preview.js +870 -0
- package/lib/index.js +976 -0
- package/lib/setup.js +243 -0
- package/package.json +68 -0
- package/scripts/ocr.py +111 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — host half. Runs in the dsh web GUI's server process.
|
|
3
|
+
*
|
|
4
|
+
* Hot-pluggable DSH plugin (same architecture as @windypro-rourou/dsh-logcat):
|
|
5
|
+
* - DevicePreviewEngine (lib/device-preview.js) owns preview servers
|
|
6
|
+
* (serve-sim / serve-avd), Metro bundlers, and build-install-launch runs.
|
|
7
|
+
* - HTTP routes under /api/dsh-mobilecode — the browser pane's read/write
|
|
8
|
+
* surface (the mobilecode `server.devicePreview` group, renamed so it
|
|
9
|
+
* cannot collide with other plugins).
|
|
10
|
+
* - Agent tools device_run + device_detect — the mobilecode
|
|
11
|
+
* `tool/device-run` contract, with an explicit `directory` parameter
|
|
12
|
+
* because DSH tools have no session-location concept.
|
|
13
|
+
* - A system-prompt guidance section telling the agent these tools exist.
|
|
14
|
+
*
|
|
15
|
+
* All routes are loopback-only (the pane lives in the same browser).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
19
|
+
import * as DeviceBuild from './device-build.js'
|
|
20
|
+
import { DevicePreviewEngine } from './device-preview.js'
|
|
21
|
+
import * as Setup from './setup.js'
|
|
22
|
+
|
|
23
|
+
export const name = 'mobilecode'
|
|
24
|
+
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
25
|
+
export const provide = ['mobilecode']
|
|
26
|
+
|
|
27
|
+
const SECTION_ORDER = 150
|
|
28
|
+
|
|
29
|
+
const API_BASE = '/api/dsh-mobilecode'
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60_000
|
|
32
|
+
const MAX_TIMEOUT_MS = 30 * 60_000
|
|
33
|
+
const POLL_MS = 2000
|
|
34
|
+
const LOG_TAIL = 60
|
|
35
|
+
const BUSY = ['building', 'installing', 'launching']
|
|
36
|
+
|
|
37
|
+
function isLoopbackRequest(req) {
|
|
38
|
+
const address = req.socket?.remoteAddress ?? ''
|
|
39
|
+
const host = req.headers?.host ?? ''
|
|
40
|
+
const okAddress = address === '::1' || address === '127.0.0.1' || address.startsWith('::ffff:127.') || address.startsWith('127.')
|
|
41
|
+
if (!okAddress) return false
|
|
42
|
+
let hostUrl
|
|
43
|
+
try { hostUrl = new URL('http://' + host) } catch { return false }
|
|
44
|
+
if (hostUrl.hostname !== 'localhost' && !hostUrl.hostname.startsWith('127.')) return false
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeJson(res, status, body) {
|
|
49
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
50
|
+
res.end(JSON.stringify(body))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readBody(req, res) {
|
|
54
|
+
const chunks = []
|
|
55
|
+
let size = 0
|
|
56
|
+
for await (const chunk of req) {
|
|
57
|
+
size += chunk.length
|
|
58
|
+
if (size > 64 * 1024) { writeJson(res, 413, { error: 'body too large' }); return undefined }
|
|
59
|
+
chunks.push(chunk)
|
|
60
|
+
}
|
|
61
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return {} }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A directory the caller asked for, or the configured/working default. */
|
|
65
|
+
function resolveDirectory(body, config) {
|
|
66
|
+
if (typeof body?.directory === 'string' && body.directory !== '') return body.directory
|
|
67
|
+
if (typeof config?.defaultDirectory === 'string' && config.defaultDirectory !== '') return config.defaultDirectory
|
|
68
|
+
return process.cwd()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeRoutes(engine, config) {
|
|
72
|
+
const guard = (req, res) => {
|
|
73
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return false }
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
|
|
77
|
+
const routes = [
|
|
78
|
+
// GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
|
|
79
|
+
{
|
|
80
|
+
kind: 'exact',
|
|
81
|
+
path: API_BASE,
|
|
82
|
+
handler: async (req, res) => {
|
|
83
|
+
if (!guard(req, res)) return
|
|
84
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
85
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
86
|
+
const directory = url.searchParams.get('directory') ?? resolveDirectory({}, config)
|
|
87
|
+
try {
|
|
88
|
+
writeJson(res, 200, await engine.info({ directory }))
|
|
89
|
+
} catch (error) {
|
|
90
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
// POST /api/dsh-mobilecode/start {directory?, platform} — start the preview server.
|
|
95
|
+
{
|
|
96
|
+
kind: 'exact',
|
|
97
|
+
path: API_BASE + '/start',
|
|
98
|
+
handler: async (req, res) => {
|
|
99
|
+
if (!guard(req, res)) return
|
|
100
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
101
|
+
const body = await readBody(req, res)
|
|
102
|
+
if (body === undefined) return
|
|
103
|
+
const platform = platformOf(body.platform)
|
|
104
|
+
if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
|
|
105
|
+
try {
|
|
106
|
+
writeJson(res, 200, await engine.start({ directory: resolveDirectory(body, config), platform }))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
// POST /api/dsh-mobilecode/stop {directory?, platform} — stop the preview server.
|
|
113
|
+
{
|
|
114
|
+
kind: 'exact',
|
|
115
|
+
path: API_BASE + '/stop',
|
|
116
|
+
handler: async (req, res) => {
|
|
117
|
+
if (!guard(req, res)) return
|
|
118
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
119
|
+
const body = await readBody(req, res)
|
|
120
|
+
if (body === undefined) return
|
|
121
|
+
const platform = platformOf(body.platform)
|
|
122
|
+
if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
|
|
123
|
+
try {
|
|
124
|
+
writeJson(res, 200, await engine.stop({ directory: resolveDirectory(body, config), platform }))
|
|
125
|
+
} catch (error) {
|
|
126
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
// POST /api/dsh-mobilecode/run {directory?, platform, relaunch?} — build, install, launch.
|
|
131
|
+
{
|
|
132
|
+
kind: 'exact',
|
|
133
|
+
path: API_BASE + '/run',
|
|
134
|
+
handler: async (req, res) => {
|
|
135
|
+
if (!guard(req, res)) return
|
|
136
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
137
|
+
const body = await readBody(req, res)
|
|
138
|
+
if (body === undefined) return
|
|
139
|
+
const platform = platformOf(body.platform)
|
|
140
|
+
if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
|
|
141
|
+
try {
|
|
142
|
+
const directory = resolveDirectory(body, config)
|
|
143
|
+
const info = await engine.runApp({ directory, platform, relaunch: body.relaunch === true })
|
|
144
|
+
writeJson(res, 200, info)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
// POST /api/dsh-mobilecode/run/stop {directory?, platform} — cancel the build or quit the app.
|
|
151
|
+
{
|
|
152
|
+
kind: 'exact',
|
|
153
|
+
path: API_BASE + '/run/stop',
|
|
154
|
+
handler: async (req, res) => {
|
|
155
|
+
if (!guard(req, res)) return
|
|
156
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
157
|
+
const body = await readBody(req, res)
|
|
158
|
+
if (body === undefined) return
|
|
159
|
+
const platform = platformOf(body.platform)
|
|
160
|
+
if (!platform) { writeJson(res, 400, { error: 'platform is required (ios|android)' }); return }
|
|
161
|
+
try {
|
|
162
|
+
writeJson(res, 200, await engine.stopApp({ directory: resolveDirectory(body, config), platform }))
|
|
163
|
+
} catch (error) {
|
|
164
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
// POST /api/dsh-mobilecode/focus {directory?} — user switched to this directory: park others.
|
|
169
|
+
{
|
|
170
|
+
kind: 'exact',
|
|
171
|
+
path: API_BASE + '/focus',
|
|
172
|
+
handler: async (req, res) => {
|
|
173
|
+
if (!guard(req, res)) return
|
|
174
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
175
|
+
const body = await readBody(req, res)
|
|
176
|
+
if (body === undefined) return
|
|
177
|
+
try {
|
|
178
|
+
writeJson(res, 200, await engine.focus({ directory: resolveDirectory(body, config) }))
|
|
179
|
+
} catch (error) {
|
|
180
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
// GET /api/dsh-mobilecode/welcome → { show, prompt } — first-run flag + the copy-paste AI prompt.
|
|
185
|
+
{
|
|
186
|
+
kind: 'exact',
|
|
187
|
+
path: API_BASE + '/welcome',
|
|
188
|
+
handler: async (req, res) => {
|
|
189
|
+
if (!guard(req, res)) return
|
|
190
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
191
|
+
const settings = Setup.readSettings()
|
|
192
|
+
writeJson(res, 200, { show: settings.welcomeDismissed !== true, prompt: Setup.WELCOME_PROMPT })
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
// POST /api/dsh-mobilecode/welcome/dismiss — never show the welcome window again.
|
|
196
|
+
{
|
|
197
|
+
kind: 'exact',
|
|
198
|
+
path: API_BASE + '/welcome/dismiss',
|
|
199
|
+
handler: async (req, res) => {
|
|
200
|
+
if (!guard(req, res)) return
|
|
201
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
202
|
+
await readBody(req, res)
|
|
203
|
+
Setup.writeSettings({ welcomeDismissed: true })
|
|
204
|
+
writeJson(res, 200, { ok: true })
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
// GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
|
|
208
|
+
{
|
|
209
|
+
kind: 'exact',
|
|
210
|
+
path: API_BASE + '/doctor',
|
|
211
|
+
handler: async (req, res) => {
|
|
212
|
+
if (!guard(req, res)) return
|
|
213
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
214
|
+
try {
|
|
215
|
+
const checks = await Setup.runDoctor()
|
|
216
|
+
writeJson(res, 200, { checks })
|
|
217
|
+
} catch (error) {
|
|
218
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
// POST /api/dsh-mobilecode/doctor/fix {id} — auto-fix one failing check (e.g. paddleocr).
|
|
223
|
+
{
|
|
224
|
+
kind: 'exact',
|
|
225
|
+
path: API_BASE + '/doctor/fix',
|
|
226
|
+
handler: async (req, res) => {
|
|
227
|
+
if (!guard(req, res)) return
|
|
228
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
229
|
+
const body = await readBody(req, res)
|
|
230
|
+
if (body === undefined) return
|
|
231
|
+
if (typeof body.id !== 'string' || body.id === '') { writeJson(res, 400, { error: 'id is required' }); return }
|
|
232
|
+
try {
|
|
233
|
+
writeJson(res, 200, await Setup.runFix(body.id))
|
|
234
|
+
} catch (error) {
|
|
235
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
// GET /api/dsh-mobilecode/ocr → {installed, working, state, log} — PaddleOCR install state.
|
|
240
|
+
{
|
|
241
|
+
kind: 'exact',
|
|
242
|
+
path: API_BASE + '/ocr',
|
|
243
|
+
handler: async (req, res) => {
|
|
244
|
+
if (!guard(req, res)) return
|
|
245
|
+
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
246
|
+
try {
|
|
247
|
+
writeJson(res, 200, await Setup.ocrStatus())
|
|
248
|
+
} catch (error) {
|
|
249
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
// POST /api/dsh-mobilecode/ocr/install — kick off the one-click PaddleOCR install.
|
|
254
|
+
{
|
|
255
|
+
kind: 'exact',
|
|
256
|
+
path: API_BASE + '/ocr/install',
|
|
257
|
+
handler: async (req, res) => {
|
|
258
|
+
if (!guard(req, res)) return
|
|
259
|
+
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
260
|
+
await readBody(req, res)
|
|
261
|
+
try {
|
|
262
|
+
writeJson(res, 200, Setup.startOcrInstall())
|
|
263
|
+
} catch (error) {
|
|
264
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
// GET/POST /api/dsh-mobilecode/settings — persisted plugin settings.
|
|
269
|
+
{
|
|
270
|
+
kind: 'exact',
|
|
271
|
+
path: API_BASE + '/settings',
|
|
272
|
+
handler: async (req, res) => {
|
|
273
|
+
if (!guard(req, res)) return
|
|
274
|
+
const method = req.method ?? 'GET'
|
|
275
|
+
if (method === 'GET') {
|
|
276
|
+
writeJson(res, 200, Setup.readSettings())
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (method === 'POST') {
|
|
280
|
+
const body = await readBody(req, res)
|
|
281
|
+
if (body === undefined) return
|
|
282
|
+
const allowed = ['defaultDirectory']
|
|
283
|
+
const patch = {}
|
|
284
|
+
for (const key of allowed) if (typeof body?.[key] === 'string' && body[key] !== '') patch[key] = body[key]
|
|
285
|
+
writeJson(res, 200, Setup.writeSettings(patch))
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
writeJson(res, 405, { error: 'method not allowed' })
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
return routes
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── agent tools ───────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
const label = (platform) => (platform === 'ios' ? 'iOS' : 'Android')
|
|
298
|
+
|
|
299
|
+
/** The mobilecode BuildSummary → the strict DSH output schema. */
|
|
300
|
+
function summarize(info, targets, complete = true) {
|
|
301
|
+
const builds = targets.flatMap((platform) => {
|
|
302
|
+
const build = info.builds.find((item) => item.platform === platform)
|
|
303
|
+
if (!build) return []
|
|
304
|
+
const failed = build.status === 'failed'
|
|
305
|
+
const out = {
|
|
306
|
+
platform,
|
|
307
|
+
status: build.status,
|
|
308
|
+
log: failed || !complete ? build.log.slice(-LOG_TAIL) : [],
|
|
309
|
+
}
|
|
310
|
+
if (build.step !== undefined) out.step = build.step
|
|
311
|
+
if (build.target !== undefined) out.target = build.target
|
|
312
|
+
if (build.appID !== undefined) out.appID = build.appID
|
|
313
|
+
if (build.error !== undefined) out.error = build.error
|
|
314
|
+
return [out]
|
|
315
|
+
})
|
|
316
|
+
const out = { platforms: info.platforms, builds, complete }
|
|
317
|
+
if (info.framework !== undefined) out.framework = info.framework
|
|
318
|
+
if (info.bundler) {
|
|
319
|
+
out.bundler = {
|
|
320
|
+
status: info.bundler.status,
|
|
321
|
+
log: info.bundler.status === 'exited' || !complete ? info.bundler.log.slice(-LOG_TAIL) : [],
|
|
322
|
+
}
|
|
323
|
+
if (info.bundler.url !== undefined) out.bundler.url = info.bundler.url
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function toModelOutput(output) {
|
|
329
|
+
const lines = []
|
|
330
|
+
if (output.framework) lines.push(`Framework: ${output.framework}`)
|
|
331
|
+
if (output.builds.length === 0) lines.push('No app has been run yet.')
|
|
332
|
+
for (const build of output.builds) {
|
|
333
|
+
const detail = [build.appID, build.target && `target ${build.target}`].filter(Boolean).join(', ')
|
|
334
|
+
const head = `${label(build.platform)}: ${build.status}${build.step ? ` (${build.step})` : ''}${detail ? ` — ${detail}` : ''}`
|
|
335
|
+
lines.push(build.error ? `${head}\n ${build.error}` : head)
|
|
336
|
+
if (build.log.length > 0) lines.push(build.log.map((line) => ` | ${line}`).join('\n'))
|
|
337
|
+
}
|
|
338
|
+
if (output.bundler) {
|
|
339
|
+
lines.push(`Metro: ${output.bundler.status}${output.bundler.url ? ` at ${output.bundler.url}` : ''}`)
|
|
340
|
+
if (output.bundler.log.length > 0) lines.push(output.bundler.log.map((line) => ` | ${line}`).join('\n'))
|
|
341
|
+
}
|
|
342
|
+
if (!output.complete)
|
|
343
|
+
lines.push('Timed out waiting for the run to finish; it is still in progress. Call again with action "status" to check on it.')
|
|
344
|
+
return lines.join('\n')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function deviceRunTool(engine, config) {
|
|
348
|
+
return defineTool({
|
|
349
|
+
name: 'device_run',
|
|
350
|
+
description: 'Build, install and launch the mobile app in this project on the iOS Simulator and Android Emulator, ' +
|
|
351
|
+
'the same way the Play button in the device pane does, and wait for the result. ' +
|
|
352
|
+
'Works for Expo (prebuild runs automatically), React Native (Metro is started for you) and plain native projects. ' +
|
|
353
|
+
'Use it after creating or changing a mobile app to check that it builds and launches; when a build fails, ' +
|
|
354
|
+
'the reported error and log tail say why, so fix that and run again. The user sees the app running in the device pane.',
|
|
355
|
+
parameters: {
|
|
356
|
+
action: {
|
|
357
|
+
type: 'string',
|
|
358
|
+
enum: ['run', 'stop', 'status'],
|
|
359
|
+
description: 'run: build, install and launch the app, then wait for the result. ' +
|
|
360
|
+
'stop: terminate the running app or cancel its build. status: report the current state without changing anything.',
|
|
361
|
+
},
|
|
362
|
+
platform: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
enum: ['ios', 'android', 'all'],
|
|
365
|
+
description: 'Which device to target. Defaults to all detected platforms.',
|
|
366
|
+
},
|
|
367
|
+
directory: {
|
|
368
|
+
type: 'string',
|
|
369
|
+
description: 'Absolute path to the mobile project to operate on. Required when the working directory is not the project root.',
|
|
370
|
+
},
|
|
371
|
+
timeout: {
|
|
372
|
+
type: 'integer',
|
|
373
|
+
description: `How long to wait for a run to finish, in milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}).`,
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
output: {
|
|
377
|
+
schema: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
additionalProperties: false,
|
|
380
|
+
properties: {
|
|
381
|
+
framework: { type: 'string', enum: ['expo', 'react-native', 'native'] },
|
|
382
|
+
platforms: {
|
|
383
|
+
type: 'array',
|
|
384
|
+
required: true,
|
|
385
|
+
items: { type: 'string', enum: ['ios', 'android'] },
|
|
386
|
+
},
|
|
387
|
+
builds: {
|
|
388
|
+
type: 'array',
|
|
389
|
+
required: true,
|
|
390
|
+
items: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
additionalProperties: false,
|
|
393
|
+
properties: {
|
|
394
|
+
platform: { type: 'string', enum: ['ios', 'android'], required: true },
|
|
395
|
+
status: { type: 'string', enum: ['idle', 'building', 'installing', 'launching', 'running', 'failed'], required: true },
|
|
396
|
+
step: { type: 'string' },
|
|
397
|
+
target: { type: 'string' },
|
|
398
|
+
appID: { type: 'string' },
|
|
399
|
+
error: { type: 'string' },
|
|
400
|
+
log: { type: 'array', required: true, items: { type: 'string' } },
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
bundler: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
additionalProperties: false,
|
|
407
|
+
properties: {
|
|
408
|
+
status: { type: 'string', enum: ['starting', 'running', 'exited'], required: true },
|
|
409
|
+
url: { type: 'string' },
|
|
410
|
+
log: { type: 'array', required: true, items: { type: 'string' } },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
complete: { type: 'boolean', required: true },
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
render: (_args, value) => [{ type: 'text', text: toModelOutput(value ?? { platforms: [], builds: [], complete: true }) }],
|
|
417
|
+
},
|
|
418
|
+
async execute(args) {
|
|
419
|
+
const wanted = args.platform ?? 'all'
|
|
420
|
+
const directory = typeof args.directory === 'string' && args.directory !== '' ? args.directory : resolveDirectory({}, config)
|
|
421
|
+
const info = await engine.info({ directory })
|
|
422
|
+
const targets = wanted === 'all' ? info.platforms : [wanted]
|
|
423
|
+
if (info.platforms.length === 0) {
|
|
424
|
+
throw new Error('No iOS or Android project was found at or below this directory.')
|
|
425
|
+
}
|
|
426
|
+
const missing = targets.filter((platform) => !info.platforms.includes(platform))
|
|
427
|
+
if (missing.length > 0) {
|
|
428
|
+
throw new Error(`No ${label(missing[0])} project was found. Detected: ${info.platforms.map(label).join(', ')}.`)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (args.action === 'status') return summarize(info, targets)
|
|
432
|
+
|
|
433
|
+
if (args.action === 'stop') {
|
|
434
|
+
let latest = info
|
|
435
|
+
for (const platform of targets) latest = await engine.stopApp({ directory, platform })
|
|
436
|
+
return summarize(latest, targets)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let latest = info
|
|
440
|
+
for (const platform of targets) latest = await engine.runApp({ directory, platform })
|
|
441
|
+
const timeout = Math.min(Math.max(args.timeout ?? DEFAULT_TIMEOUT_MS, 0), MAX_TIMEOUT_MS)
|
|
442
|
+
const deadline = Date.now() + timeout
|
|
443
|
+
const busy = (current) =>
|
|
444
|
+
targets.some((platform) => {
|
|
445
|
+
const build = current.builds.find((item) => item.platform === platform)
|
|
446
|
+
return !!build && BUSY.includes(build.status)
|
|
447
|
+
})
|
|
448
|
+
while (busy(latest)) {
|
|
449
|
+
if (Date.now() >= deadline) return summarize(latest, targets, false)
|
|
450
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
|
|
451
|
+
latest = await engine.info({ directory })
|
|
452
|
+
}
|
|
453
|
+
return summarize(latest, targets)
|
|
454
|
+
},
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function deviceDetectTool(engine, config) {
|
|
459
|
+
return defineTool({
|
|
460
|
+
name: 'device_detect',
|
|
461
|
+
description: 'Detect the mobile platforms (iOS Simulator / Android Emulator) in a directory and report what is ' +
|
|
462
|
+
'attached: which platforms the project supports, its framework, and the first attached Android device. ' +
|
|
463
|
+
'Call this before device_run to learn whether the project has an iOS and/or Android target and what to expect.',
|
|
464
|
+
parameters: {
|
|
465
|
+
directory: {
|
|
466
|
+
type: 'string',
|
|
467
|
+
description: 'Absolute path to the mobile project to inspect. Required when the working directory is not the project root.',
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
output: {
|
|
471
|
+
schema: {
|
|
472
|
+
type: 'object',
|
|
473
|
+
additionalProperties: false,
|
|
474
|
+
properties: {
|
|
475
|
+
directory: { type: 'string', required: true },
|
|
476
|
+
platforms: {
|
|
477
|
+
type: 'array',
|
|
478
|
+
required: true,
|
|
479
|
+
items: { type: 'string', enum: ['ios', 'android'] },
|
|
480
|
+
},
|
|
481
|
+
framework: { type: 'string', enum: ['expo', 'react-native', 'native'] },
|
|
482
|
+
device: { type: 'string' },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
render: (_args, value) => {
|
|
486
|
+
const v = value ?? { directory: '', platforms: [] }
|
|
487
|
+
const lines = [`Directory: ${v.directory}`, `Platforms: ${v.platforms.length > 0 ? v.platforms.map(label).join(', ') : '(none detected)'}`]
|
|
488
|
+
if (v.framework) lines.push(`Framework: ${v.framework}`)
|
|
489
|
+
if (v.device) lines.push(`Android device: ${v.device}`)
|
|
490
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
async execute(args) {
|
|
494
|
+
const directory = typeof args.directory === 'string' && args.directory !== '' ? args.directory : resolveDirectory({}, config)
|
|
495
|
+
const projects = DeviceBuild.findProjects(directory)
|
|
496
|
+
const info = await engine.info({ directory })
|
|
497
|
+
let device
|
|
498
|
+
try { device = await DeviceBuild.androidDevice() } catch { /* no adb */ }
|
|
499
|
+
const out = {
|
|
500
|
+
directory,
|
|
501
|
+
platforms: projects.map((project) => project.platform),
|
|
502
|
+
framework: info.framework,
|
|
503
|
+
}
|
|
504
|
+
if (device) out.device = device
|
|
505
|
+
return out
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** First attached (or explicit) Android device serial; throws a friendly error when none. */
|
|
511
|
+
async function requireAndroidDevice(serial) {
|
|
512
|
+
const target = serial && serial !== '' ? serial : await DeviceBuild.androidDevice()
|
|
513
|
+
if (!target) throw new Error('No Android device is attached. Boot one (device_run on an Android project does it automatically), or pass a serial.')
|
|
514
|
+
return target
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Common key names → Android keycode. Anything else can be passed as a raw keycode integer. */
|
|
518
|
+
const KEYCODES = {
|
|
519
|
+
back: 4, home: 3, menu: 82, recents: 187, app_switch: 187, enter: 66, tab: 61, space: 62,
|
|
520
|
+
delete: 67, backspace: 67, escape: 111, search: 84, camera: 27, power: 26, volume_up: 24,
|
|
521
|
+
volume_down: 25, dpad_up: 19, dpad_down: 20, dpad_left: 21, dpad_right: 22, wakeup: 224,
|
|
522
|
+
sleep: 223, dial: 5, endcall: 6, clear: 28,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function deviceInputTool() {
|
|
526
|
+
return defineTool({
|
|
527
|
+
name: 'device_input',
|
|
528
|
+
description: 'Send input to an attached Android device: tap at pixel coordinates (from device_screen), swipe, type text, ' +
|
|
529
|
+
'or press a hardware key. Coordinates are ABSOLUTE physical pixels — the same space device_screen returns, so take ' +
|
|
530
|
+
'the center of an OCR/UI box ((x1+x2)/2, (y1+y2)/2). Works for native, Compose, React Native and Expo apps.',
|
|
531
|
+
parameters: {
|
|
532
|
+
serial: {
|
|
533
|
+
type: 'string',
|
|
534
|
+
description: 'Android device serial. Omit to use the first attached device.',
|
|
535
|
+
},
|
|
536
|
+
action: {
|
|
537
|
+
type: 'string',
|
|
538
|
+
enum: ['tap', 'swipe', 'text', 'key'],
|
|
539
|
+
description: 'What to send: tap (x,y), swipe (x1,y1 → x2,y2, optional duration ms), text (ASCII, spaces ok), or key (named key or raw keycode).',
|
|
540
|
+
},
|
|
541
|
+
x: { type: 'integer', description: 'Pixel X for tap, or swipe start X.' },
|
|
542
|
+
y: { type: 'integer', description: 'Pixel Y for tap, or swipe start Y.' },
|
|
543
|
+
x2: { type: 'integer', description: 'Swipe end X (action=swipe only).' },
|
|
544
|
+
y2: { type: 'integer', description: 'Swipe end Y (action=swipe only).' },
|
|
545
|
+
duration: { type: 'integer', description: 'Swipe duration in ms (default 200).' },
|
|
546
|
+
text: { type: 'string', description: 'Text to type (action=text; ASCII only, spaces supported).' },
|
|
547
|
+
key: { type: 'string', description: 'Key name (back, home, enter, tab, delete, volume_up, …) or a raw keycode integer (action=key).' },
|
|
548
|
+
},
|
|
549
|
+
output: {
|
|
550
|
+
schema: {
|
|
551
|
+
type: 'object',
|
|
552
|
+
additionalProperties: false,
|
|
553
|
+
properties: {
|
|
554
|
+
serial: { type: 'string', required: true },
|
|
555
|
+
action: { type: 'string', required: true },
|
|
556
|
+
sent: { type: 'string', required: true },
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
render: (_args, value) => {
|
|
560
|
+
const v = value ?? { serial: '', action: '', sent: '' }
|
|
561
|
+
return [{ type: 'text', text: `Sent ${v.action} (${v.sent}) to ${v.serial}` }]
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
async execute(args) {
|
|
565
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
566
|
+
const action = args.action ?? 'tap'
|
|
567
|
+
const adbArgs = (shell) => ['-s', serial, 'shell', ...shell]
|
|
568
|
+
switch (action) {
|
|
569
|
+
case 'tap': {
|
|
570
|
+
if (typeof args.x !== 'number' || typeof args.y !== 'number') throw new Error('action=tap requires x and y (integer pixels).')
|
|
571
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'tap', String(args.x), String(args.y)])).exit
|
|
572
|
+
return { serial, action, sent: `tap ${args.x},${args.y}` }
|
|
573
|
+
}
|
|
574
|
+
case 'swipe': {
|
|
575
|
+
if (typeof args.x !== 'number' || typeof args.y !== 'number' || typeof args.x2 !== 'number' || typeof args.y2 !== 'number') {
|
|
576
|
+
throw new Error('action=swipe requires x, y, x2, y2.')
|
|
577
|
+
}
|
|
578
|
+
const duration = args.duration ?? 200
|
|
579
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'swipe', String(args.x), String(args.y), String(args.x2), String(args.y2), String(duration)])).exit
|
|
580
|
+
return { serial, action, sent: `swipe ${args.x},${args.y}→${args.x2},${args.y2} (${duration}ms)` }
|
|
581
|
+
}
|
|
582
|
+
case 'text': {
|
|
583
|
+
if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
|
|
584
|
+
const escaped = args.text.replace(/\s/g, '%s')
|
|
585
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', escaped])).exit
|
|
586
|
+
return { serial, action, sent: `text "${args.text}"` }
|
|
587
|
+
}
|
|
588
|
+
case 'key': {
|
|
589
|
+
const raw = String(args.key ?? '')
|
|
590
|
+
const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
|
|
591
|
+
if (!code) throw new Error(`unknown key "${raw}" — use a name from ${Object.keys(KEYCODES).join(', ')} or a raw keycode integer.`)
|
|
592
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'keyevent', String(code)])).exit
|
|
593
|
+
return { serial, action, sent: `key ${raw} (${code})` }
|
|
594
|
+
}
|
|
595
|
+
default:
|
|
596
|
+
throw new Error(`unknown action "${action}" — use tap, swipe, text or key.`)
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function deviceScreenTool(engine) {
|
|
603
|
+
return defineTool({
|
|
604
|
+
name: 'device_screen',
|
|
605
|
+
description: 'See what is on an attached Android device right now: captures the screen as a PNG file, dumps the UI ' +
|
|
606
|
+
'hierarchy (uiautomator) with pixel bounds, and OCRs the pixels with local PaddleOCR (text + coordinates). ' +
|
|
607
|
+
'Use this after device_run to confirm the app rendered, to read what the app shows, and to drive UI flows by ' +
|
|
608
|
+
'tapping the returned coordinates with device_input. Works for native, Compose, React Native and Expo apps; ' +
|
|
609
|
+
'OCR also covers WebViews, games and other surfaces that expose no accessibility labels.',
|
|
610
|
+
parameters: {
|
|
611
|
+
serial: {
|
|
612
|
+
type: 'string',
|
|
613
|
+
description: 'Android device serial. Omit to use the first attached device.',
|
|
614
|
+
},
|
|
615
|
+
ocr: {
|
|
616
|
+
type: 'boolean',
|
|
617
|
+
description: 'Whether to run PaddleOCR over the screenshot (default true). Set false when OCR is not needed — it can take a few seconds.',
|
|
618
|
+
},
|
|
619
|
+
directory: {
|
|
620
|
+
type: 'string',
|
|
621
|
+
description: 'Where to store the screenshot PNG (default: a temp directory). The path is returned so a multimodal model can read it.',
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
output: {
|
|
625
|
+
schema: {
|
|
626
|
+
type: 'object',
|
|
627
|
+
additionalProperties: false,
|
|
628
|
+
properties: {
|
|
629
|
+
serial: { type: 'string', required: true },
|
|
630
|
+
screenshot: { type: 'string' },
|
|
631
|
+
width: { type: 'integer' },
|
|
632
|
+
height: { type: 'integer' },
|
|
633
|
+
foreground: { type: 'string' },
|
|
634
|
+
ui: {
|
|
635
|
+
type: 'array',
|
|
636
|
+
items: {
|
|
637
|
+
type: 'object',
|
|
638
|
+
additionalProperties: false,
|
|
639
|
+
properties: {
|
|
640
|
+
text: { type: 'string', required: true },
|
|
641
|
+
resourceId: { type: 'string' },
|
|
642
|
+
bounds: { type: 'array', items: { type: 'integer' } },
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
ocr: {
|
|
647
|
+
type: 'array',
|
|
648
|
+
items: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
additionalProperties: false,
|
|
651
|
+
properties: {
|
|
652
|
+
text: { type: 'string', required: true },
|
|
653
|
+
confidence: { type: 'number' },
|
|
654
|
+
box: { type: 'array', items: { type: 'integer' } },
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
ocrError: { type: 'string' },
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
render: (_args, value) => {
|
|
662
|
+
const v = value ?? { serial: '' }
|
|
663
|
+
const lines = [`Device: ${v.serial}${v.foreground ? ` — foreground: ${v.foreground}` : ''}`]
|
|
664
|
+
if (v.screenshot) lines.push(`Screenshot: ${v.screenshot}${v.width ? ` (${v.width}x${v.height})` : ''}`)
|
|
665
|
+
if (v.ocrError) lines.push(`OCR unavailable: ${v.ocrError}`)
|
|
666
|
+
if (v.ui && v.ui.length > 0) {
|
|
667
|
+
lines.push('UI hierarchy:')
|
|
668
|
+
for (const item of v.ui.slice(0, 40)) {
|
|
669
|
+
const where = item.bounds ? ` @${item.bounds.join(',')}` : ''
|
|
670
|
+
lines.push(` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${where}`)
|
|
671
|
+
}
|
|
672
|
+
if (v.ui.length > 40) lines.push(` … and ${v.ui.length - 40} more`)
|
|
673
|
+
}
|
|
674
|
+
if (v.ocr && v.ocr.length > 0) {
|
|
675
|
+
lines.push('OCR (local PaddleOCR):')
|
|
676
|
+
for (const item of v.ocr.slice(0, 40)) {
|
|
677
|
+
lines.push(` - "${item.text}" (${Math.round(item.confidence * 100)}%) box=${item.box.join(',')}`)
|
|
678
|
+
}
|
|
679
|
+
if (v.ocr.length > 40) lines.push(` … and ${v.ocr.length - 40} more`)
|
|
680
|
+
}
|
|
681
|
+
if (!v.ui?.length && !v.ocr?.length) lines.push('No text found on screen.')
|
|
682
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
async execute(args) {
|
|
686
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
687
|
+
const png = await DeviceBuild.screenCapture(serial, args.directory)
|
|
688
|
+
const [ui, foreground, size] = await Promise.all([
|
|
689
|
+
DeviceBuild.uiDump(serial).catch(() => []),
|
|
690
|
+
DeviceBuild.foregroundActivity(serial).catch(() => undefined),
|
|
691
|
+
captureScreenSize(serial),
|
|
692
|
+
])
|
|
693
|
+
const out = { serial, ui, ...(foreground ? { foreground } : {}) }
|
|
694
|
+
if (png) {
|
|
695
|
+
out.screenshot = png
|
|
696
|
+
if (size) { out.width = size.width; out.height = size.height }
|
|
697
|
+
}
|
|
698
|
+
if (args.ocr !== false) {
|
|
699
|
+
if (!png) {
|
|
700
|
+
out.ocrError = 'screenshot failed'
|
|
701
|
+
} else if (!DeviceBuild.ocrPython()) {
|
|
702
|
+
out.ocrError = 'PaddleOCR venv not found (install with: py -3.12 -m venv ~/.dsh/mobilecode/ocr-venv; pip install paddleocr==3.7.0 paddlepaddle==3.3.1 numpy<2)'
|
|
703
|
+
} else {
|
|
704
|
+
const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
|
|
705
|
+
if (ocr.length > 0) out.ocr = ocr
|
|
706
|
+
else out.ocrError = 'PaddleOCR returned no text (or failed silently)'
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return out
|
|
710
|
+
},
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function captureScreenSize(serial) {
|
|
715
|
+
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
|
|
716
|
+
const match = /Physical size:\s*(\d+)x(\d+)/.exec(output)
|
|
717
|
+
return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function deviceLogTool(engine) {
|
|
721
|
+
return defineTool({
|
|
722
|
+
name: 'device_log',
|
|
723
|
+
description: 'Read logs from an attached Android device: logcat (main buffer, or crash/events/kernel), optionally ' +
|
|
724
|
+
'filtered to an app package, plus the kernel dmesg. Call it when a run fails or an app misbehaves to see crashes, ' +
|
|
725
|
+
'exceptions and system messages. The crash buffer holds the last fatal exceptions; kernel (dmesg) needs adb root ' +
|
|
726
|
+
'(available on emulators).',
|
|
727
|
+
parameters: {
|
|
728
|
+
serial: {
|
|
729
|
+
type: 'string',
|
|
730
|
+
description: 'Android device serial. Omit to use the first attached device.',
|
|
731
|
+
},
|
|
732
|
+
buffer: {
|
|
733
|
+
type: 'string',
|
|
734
|
+
enum: ['main', 'crash', 'events', 'kernel', 'all'],
|
|
735
|
+
description: 'Which log buffer to read: main (default), crash (fatal exceptions/ANRs), events (activity lifecycle), kernel (dmesg), or all.',
|
|
736
|
+
},
|
|
737
|
+
filter: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: 'Case-insensitive substring to keep only matching lines, e.g. an app package or an error tag.',
|
|
740
|
+
},
|
|
741
|
+
lines: {
|
|
742
|
+
type: 'integer',
|
|
743
|
+
description: 'How many log lines to read (default 200, maximum 2000).',
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
output: {
|
|
747
|
+
schema: {
|
|
748
|
+
type: 'object',
|
|
749
|
+
additionalProperties: false,
|
|
750
|
+
properties: {
|
|
751
|
+
serial: { type: 'string', required: true },
|
|
752
|
+
buffer: { type: 'string', required: true },
|
|
753
|
+
lines: { type: 'array', required: true, items: { type: 'string' } },
|
|
754
|
+
truncated: { type: 'boolean' },
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
render: (_args, value) => {
|
|
758
|
+
const v = value ?? { serial: '', buffer: 'main', lines: [] }
|
|
759
|
+
const head = `Device ${v.serial} — ${v.buffer} buffer${v.lines.length > 0 ? ` (${v.lines.length} lines)` : ' (empty)'}`
|
|
760
|
+
return [{ type: 'text', text: v.lines.length > 0 ? `${head}\n${v.lines.join('\n')}` : head }]
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
async execute(args) {
|
|
764
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
765
|
+
const buffer = args.buffer ?? 'main'
|
|
766
|
+
const lines = Math.min(Math.max(args.lines ?? 200, 1), 2000)
|
|
767
|
+
let output
|
|
768
|
+
if (buffer === 'kernel') {
|
|
769
|
+
output = await DeviceBuild.dmesg(serial).catch(() => '')
|
|
770
|
+
} else {
|
|
771
|
+
output = await DeviceBuild.logcat(serial, { buffer, lines, filter: args.filter })
|
|
772
|
+
}
|
|
773
|
+
const all = output.split('\n').filter((line) => line !== '')
|
|
774
|
+
const list = buffer === 'kernel' || !args.filter ? all : all.filter((line) => line.toLowerCase().includes(String(args.filter).toLowerCase()))
|
|
775
|
+
return { serial, buffer, lines: list.slice(-lines), ...(all.length > lines ? { truncated: true } : {}) }
|
|
776
|
+
},
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function deviceStatusTool(engine) {
|
|
781
|
+
return defineTool({
|
|
782
|
+
name: 'device_status',
|
|
783
|
+
description: 'One normalized snapshot of the mobile environment: attached Android devices, configured AVDs, whether an ' +
|
|
784
|
+
'emulator binary exists, and what this plugin currently runs (preview servers, Metro bundlers, builds per directory ' +
|
|
785
|
+
'with status/app/activity). Call this when you need to know what is attached and what is running before acting.',
|
|
786
|
+
parameters: {},
|
|
787
|
+
output: {
|
|
788
|
+
schema: {
|
|
789
|
+
type: 'object',
|
|
790
|
+
additionalProperties: false,
|
|
791
|
+
properties: {
|
|
792
|
+
devices: {
|
|
793
|
+
type: 'array',
|
|
794
|
+
items: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
properties: {
|
|
798
|
+
serial: { type: 'string', required: true },
|
|
799
|
+
state: { type: 'string', required: true },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
avds: { type: 'array', items: { type: 'string' } },
|
|
804
|
+
emulator: { type: 'string' },
|
|
805
|
+
runs: {
|
|
806
|
+
type: 'array',
|
|
807
|
+
items: {
|
|
808
|
+
type: 'object',
|
|
809
|
+
additionalProperties: false,
|
|
810
|
+
properties: {
|
|
811
|
+
directory: { type: 'string', required: true },
|
|
812
|
+
platform: { type: 'string', enum: ['ios', 'android'], required: true },
|
|
813
|
+
status: { type: 'string', required: true },
|
|
814
|
+
appID: { type: 'string' },
|
|
815
|
+
step: { type: 'string' },
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
metro: {
|
|
820
|
+
type: 'object',
|
|
821
|
+
additionalProperties: false,
|
|
822
|
+
properties: {
|
|
823
|
+
directory: { type: 'string' },
|
|
824
|
+
status: { type: 'string' },
|
|
825
|
+
url: { type: 'string' },
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
servers: {
|
|
829
|
+
type: 'array',
|
|
830
|
+
items: {
|
|
831
|
+
type: 'object',
|
|
832
|
+
additionalProperties: false,
|
|
833
|
+
properties: {
|
|
834
|
+
platform: { type: 'string', enum: ['ios', 'android'], required: true },
|
|
835
|
+
status: { type: 'string', required: true },
|
|
836
|
+
url: { type: 'string' },
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
render: (_args, value) => {
|
|
843
|
+
const v = value ?? {}
|
|
844
|
+
const lines = ['Devices:']
|
|
845
|
+
if (!v.devices || v.devices.length === 0) lines.push(' (none attached)')
|
|
846
|
+
for (const device of v.devices ?? []) lines.push(` - ${device.serial} [${device.state}]`)
|
|
847
|
+
lines.push(`AVDs: ${v.avds && v.avds.length > 0 ? v.avds.join(', ') : '(none)'}${v.emulator ? ` (emulator: ${v.emulator})` : ''}`)
|
|
848
|
+
lines.push('Runs:')
|
|
849
|
+
if (!v.runs || v.runs.length === 0) lines.push(' (nothing running)')
|
|
850
|
+
for (const run of v.runs ?? []) lines.push(` - ${run.directory} ${run.platform}: ${run.status}${run.step ? ` (${run.step})` : ''}${run.appID ? ` — ${run.appID}` : ''}`)
|
|
851
|
+
if (v.metro) lines.push(`Metro: ${v.metro.status}${v.metro.url ? ` at ${v.metro.url}` : ''}${v.metro.directory ? ` (${v.metro.directory})` : ''}`)
|
|
852
|
+
if (v.servers && v.servers.length > 0) {
|
|
853
|
+
lines.push('Preview servers:')
|
|
854
|
+
for (const server of v.servers) lines.push(` - ${server.platform}: ${server.status}${server.url ? ` ${server.url}` : ''}`)
|
|
855
|
+
}
|
|
856
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
async execute() {
|
|
860
|
+
const info = await engine.info({ directory: resolveDirectory({}, {}) }).catch(() => undefined)
|
|
861
|
+
const [devices, avds] = await Promise.all([
|
|
862
|
+
DeviceBuild.devices().catch(() => []),
|
|
863
|
+
DeviceBuild.androidAvds().catch(() => []),
|
|
864
|
+
])
|
|
865
|
+
const emulator = DeviceBuild.emulatorBinary()
|
|
866
|
+
const out = { devices, avds, ...(emulator ? { emulator } : {}) }
|
|
867
|
+
if (info) {
|
|
868
|
+
const runs = []
|
|
869
|
+
for (const build of info.builds ?? []) {
|
|
870
|
+
if (build.status === 'idle' || build.status === 'failed') continue
|
|
871
|
+
const run = { directory: build.directory ?? '', platform: build.platform, status: build.status }
|
|
872
|
+
if (build.appID) run.appID = build.appID
|
|
873
|
+
if (build.step) run.step = build.step
|
|
874
|
+
runs.push(run)
|
|
875
|
+
}
|
|
876
|
+
if (runs.length > 0) out.runs = runs
|
|
877
|
+
if (info.bundler && info.bundler.status !== 'exited') {
|
|
878
|
+
out.metro = { status: info.bundler.status }
|
|
879
|
+
if (info.bundler.url) out.metro.url = info.bundler.url
|
|
880
|
+
if (info.bundler.directory) out.metro.directory = info.bundler.directory
|
|
881
|
+
}
|
|
882
|
+
if (info.servers && info.servers.length > 0) out.servers = info.servers
|
|
883
|
+
}
|
|
884
|
+
return out
|
|
885
|
+
},
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The system-prompt guidance section: what the agent can do and when to do it. */
|
|
890
|
+
function guidance() {
|
|
891
|
+
return [
|
|
892
|
+
'The dsh-mobilecode plugin is available: it can detect mobile projects, run preview servers (iOS Simulator / Android Emulator),',
|
|
893
|
+
'and build-install-launch apps the way the device pane\'s Play button does.',
|
|
894
|
+
'',
|
|
895
|
+
'Tools:',
|
|
896
|
+
'- device_detect: find which platforms (ios/android) a directory supports and what is attached. Use it first when a project may be mobile.',
|
|
897
|
+
'- device_run: build, install and launch (action "run"), cancel/stop (action "stop"), or report state (action "status").',
|
|
898
|
+
' Pass directory when the project root is not the working directory. A run takes minutes; use a generous timeout.',
|
|
899
|
+
'- device_screen: capture the attached Android screen as a PNG plus the UI hierarchy and OCR text, so you can see what',
|
|
900
|
+
' the app shows and tap by pixel coordinates. Call it after a run to confirm the app rendered, and to drive UI flows.',
|
|
901
|
+
'- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
|
|
902
|
+
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_screen → device_input → device_screen.',
|
|
903
|
+
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
904
|
+
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
905
|
+
'',
|
|
906
|
+
'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
|
|
907
|
+
'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
|
|
908
|
+
].join('\n')
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ── mount ─────────────────────────────────────────────────────────────────────
|
|
912
|
+
|
|
913
|
+
export function apply(ctx, config) {
|
|
914
|
+
Setup.ensureHome()
|
|
915
|
+
Setup.writeOcrReadme()
|
|
916
|
+
|
|
917
|
+
const resolve = () => ({
|
|
918
|
+
enabled: config?.enabled ?? true,
|
|
919
|
+
announceToAgent: config?.announceToAgent ?? true,
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
const engine = new DevicePreviewEngine()
|
|
923
|
+
const handle = {
|
|
924
|
+
engine,
|
|
925
|
+
status: () => ({
|
|
926
|
+
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
927
|
+
servers: [...engine.servers.keys()],
|
|
928
|
+
bundlers: [...engine.bundlers.keys()],
|
|
929
|
+
builds: [...engine.builds.keys()],
|
|
930
|
+
}),
|
|
931
|
+
}
|
|
932
|
+
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
933
|
+
else ctx.mobilecode = handle
|
|
934
|
+
|
|
935
|
+
const routes = makeRoutes(engine, config)
|
|
936
|
+
let disposeRoutes
|
|
937
|
+
let disposeTools
|
|
938
|
+
let disposeSection
|
|
939
|
+
|
|
940
|
+
const sync = () => {
|
|
941
|
+
const value = resolve()
|
|
942
|
+
if (disposeSection !== undefined) { disposeSection(); disposeSection = undefined }
|
|
943
|
+
if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
|
|
944
|
+
if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
|
|
945
|
+
if (!value.enabled) return
|
|
946
|
+
if (value.announceToAgent) {
|
|
947
|
+
disposeSection = ctx.systemPrompt.section({
|
|
948
|
+
name: 'plugin:dsh-mobilecode',
|
|
949
|
+
order: SECTION_ORDER,
|
|
950
|
+
text: guidance,
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
disposeRoutes = ctx.effect(() => {
|
|
954
|
+
const disposers = routes.map((route) => ctx.webServer.register(route))
|
|
955
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
956
|
+
}, 'dsh-mobilecode: routes')
|
|
957
|
+
disposeTools = ctx.effect(() => {
|
|
958
|
+
const disposers = [
|
|
959
|
+
deviceRunTool(engine, config),
|
|
960
|
+
deviceDetectTool(engine, config),
|
|
961
|
+
deviceScreenTool(engine),
|
|
962
|
+
deviceLogTool(engine),
|
|
963
|
+
deviceStatusTool(engine),
|
|
964
|
+
deviceInputTool(),
|
|
965
|
+
].map((tool) => ctx.tools.register(tool))
|
|
966
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
967
|
+
}, 'dsh-mobilecode: tools')
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
ctx.effect(() => () => {
|
|
971
|
+
void engine.dispose()
|
|
972
|
+
}, 'dsh-mobilecode: engine')
|
|
973
|
+
|
|
974
|
+
sync()
|
|
975
|
+
return sync
|
|
976
|
+
}
|