dsh-mobilecode 0.2.0 → 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/README.md +15 -0
- package/lib/device-build.js +51 -0
- package/lib/index.js +327 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,11 +67,26 @@ drawer:
|
|
|
67
67
|
lists up to 8 candidates instead of guessing, and disabled / off-screen nodes
|
|
68
68
|
are refused with the fix. `expect_text` / `expect_gone` verify the tap in the
|
|
69
69
|
same call — one round trip, no separate screenshot.
|
|
70
|
+
- `device_wait_for` — wait for text to appear or disappear: polls the UI tree
|
|
71
|
+
every ~600 ms and falls back to local PaddleOCR on textless surfaces (WebView /
|
|
72
|
+
Compose / canvas). A timeout is a normal `matched: false` result, never an
|
|
73
|
+
error — one call replaces an agent-side poll loop.
|
|
70
74
|
- `device_input` — act on the device: tap / swipe / type / press a key at
|
|
71
75
|
**absolute pixel coordinates** (the same space `device_screen` returns — take
|
|
72
76
|
the box center `x=(x1+x2)/2, y=(y1+y2)/2`). The deterministic control loop is
|
|
73
77
|
`device_ui_tree → device_tap_element`, falling back to
|
|
74
78
|
`device_screen → device_input` when a surface exposes no accessibility tree.
|
|
79
|
+
Typing is ASCII over plain adb; non-ASCII (CJK, emoji) is routed through the
|
|
80
|
+
ADBKeyboard IME when installed and refused with the install hint otherwise.
|
|
81
|
+
- `device_action` — device-level verbs beyond touches: `notifications`,
|
|
82
|
+
`quick_settings`, `collapse`, `lock`, `wake`, `assistant`, `rotate` (cycles
|
|
83
|
+
0→90→180→270 and pins auto-rotate off).
|
|
84
|
+
- `device_boot` / `device_shutdown` — boot an AVD by name and wait until it
|
|
85
|
+
finishes booting (adopts a running emulator for the same AVD) / shut an
|
|
86
|
+
emulator down (`adb emu kill`; refuses physical devices).
|
|
87
|
+
- `device_apps` / `device_launch_app` — list installed packages (third-party by
|
|
88
|
+
default) so a package name is never guessed / launch one by package or a
|
|
89
|
+
unique substring, with `relaunch` for a cold start.
|
|
75
90
|
- `device_log` — device logs: logcat `main`/`crash`/`events`/`kernel` buffers
|
|
76
91
|
(kernel = dmesg, needs adb root — works on emulators) with an optional
|
|
77
92
|
case-insensitive substring filter, capped line count.
|
package/lib/device-build.js
CHANGED
|
@@ -740,6 +740,57 @@ export async function androidBooted(serial) {
|
|
|
740
740
|
return output.trim() === "1"
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
/** AVD name of an emulator serial (`adb emu avd name`); undefined for physical/offline. */
|
|
744
|
+
export async function avdName(serial) {
|
|
745
|
+
const output = await capture(adb(), ["-s", serial, "emu", "avd", "name"])
|
|
746
|
+
const line = output
|
|
747
|
+
.split(/\r?\n/)
|
|
748
|
+
.map((item) => item.trim())
|
|
749
|
+
.find((item) => item && !/^OK$/i.test(item) && !/^KO:/i.test(item))
|
|
750
|
+
return line && /^[A-Za-z0-9._-]+$/.test(line) ? line : undefined
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Poll until sys.boot_completed == 1 or the timeout; true when booted. */
|
|
754
|
+
export async function waitForBoot(serial, timeoutMs = 180_000) {
|
|
755
|
+
const deadline = Date.now() + timeoutMs
|
|
756
|
+
for (;;) {
|
|
757
|
+
if (await androidBooted(serial)) return true
|
|
758
|
+
if (Date.now() >= deadline) return false
|
|
759
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Launch an AVD detached (survives this process); undefined when no emulator binary. */
|
|
764
|
+
export function bootEmulator(avd) {
|
|
765
|
+
const emulator = emulatorBinary()
|
|
766
|
+
if (!emulator || !/^[A-Za-z0-9._-]+$/.test(avd)) return undefined
|
|
767
|
+
const child = launch(emulator, ["-avd", avd], { stdio: "ignore", detached: true })
|
|
768
|
+
child.unref?.()
|
|
769
|
+
return child
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** True when the ADBKeyboard IME is installed (the only way to type non-ASCII over adb). */
|
|
773
|
+
export async function adbKeyboardReady(serial) {
|
|
774
|
+
const output = await capture(adb(), ["-s", serial, "shell", "ime", "list", "-s"])
|
|
775
|
+
return /com\.android\.adbkeyboard/i.test(output)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Type one string via the ADBKeyboard broadcast (base64, so any codepoint survives the shell). */
|
|
779
|
+
export async function typeViaAdbKeyboard(serial, text) {
|
|
780
|
+
const msg = Buffer.from(text, "utf8").toString("base64")
|
|
781
|
+
await exec(adb(), ["-s", serial, "shell", "am", "broadcast", "-a", "ADB_INPUT_B64", "--es", "msg", msg]).exit
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** True when every codepoint is safe for `adb shell input text` (printable ASCII, no shell metachars). */
|
|
785
|
+
export function isAsciiInput(text) {
|
|
786
|
+
return [...text].every((ch) => ch.codePointAt(0) >= 0x20 && ch.codePointAt(0) <= 0x7e)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Escape one `input text` argument for the device shell: backslash the metachars, spaces to %s. */
|
|
790
|
+
export function escapeInputText(text) {
|
|
791
|
+
return text.replace(/[\\()<>|;&*~"'`$#!{}[\]]/g, "\\$&").replace(/ /g, "%s")
|
|
792
|
+
}
|
|
793
|
+
|
|
743
794
|
function aapt2() {
|
|
744
795
|
const sdk = androidSdk()
|
|
745
796
|
if (!sdk) return undefined
|
package/lib/index.js
CHANGED
|
@@ -583,9 +583,21 @@ function deviceInputTool() {
|
|
|
583
583
|
}
|
|
584
584
|
case 'text': {
|
|
585
585
|
if (typeof args.text !== 'string' || args.text.length === 0) throw new Error('action=text requires a non-empty text string.')
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
586
|
+
if (DeviceBuild.isAsciiInput(args.text)) {
|
|
587
|
+
await DeviceBuild.exec(DeviceBuild.adb(), adbArgs(['input', 'text', DeviceBuild.escapeInputText(args.text)])).exit
|
|
588
|
+
return { serial, action, sent: `text "${args.text}"` }
|
|
589
|
+
}
|
|
590
|
+
// Non-ASCII (CJK, emoji, accented) cannot go through `input text`; the
|
|
591
|
+
// ADBKeyboard IME is the only adb path. Refuse with the fix, never mangle.
|
|
592
|
+
if (!(await DeviceBuild.adbKeyboardReady(serial))) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`device_input cannot type non-ASCII text ("${args.text}") over plain adb. Install ADBKeyboard `
|
|
595
|
+
+ 'on the device (https://github.com/senzhk/ADBKeyBoard), enable it (ime enable/set '
|
|
596
|
+
+ 'com.android.adbkeyboard/.AdbIME), then retry — this tool then types via its base64 broadcast.',
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
await DeviceBuild.typeViaAdbKeyboard(serial, args.text)
|
|
600
|
+
return { serial, action, sent: `text "${args.text}" (ADBKeyboard)` }
|
|
589
601
|
}
|
|
590
602
|
case 'key': {
|
|
591
603
|
const raw = String(args.key ?? '')
|
|
@@ -601,6 +613,301 @@ function deviceInputTool() {
|
|
|
601
613
|
})
|
|
602
614
|
}
|
|
603
615
|
|
|
616
|
+
/** OCR the current screen and report whether `wantedLower` appears; undefined when OCR is unavailable. */
|
|
617
|
+
async function ocrHasText(serial, wantedLower) {
|
|
618
|
+
if (!DeviceBuild.ocrPython()) return undefined
|
|
619
|
+
const png = await DeviceBuild.screenCapture(serial)
|
|
620
|
+
if (!png) return undefined
|
|
621
|
+
const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
|
|
622
|
+
return ocr.some((item) => String(item.text).toLowerCase().includes(wantedLower))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function deviceWaitForTool() {
|
|
626
|
+
return defineTool({
|
|
627
|
+
name: 'device_wait_for',
|
|
628
|
+
description: 'Wait for on-screen text to appear or disappear. Polls the uiautomator tree every ~600 ms; when the tree ' +
|
|
629
|
+
'carries no labels (WebView/Compose/canvas) it falls back to local PaddleOCR. A timeout is a normal matched:false ' +
|
|
630
|
+
'result, never an error — one call replaces an agent-side poll loop.',
|
|
631
|
+
parameters: {
|
|
632
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
633
|
+
text: { type: 'string', description: 'Text to wait for (case-insensitive substring).' },
|
|
634
|
+
mode: { type: 'string', enum: ['appear', 'disappear'], description: 'appear (default) waits for the text; disappear waits for it to go away.' },
|
|
635
|
+
timeout_ms: { type: 'integer', description: 'Max wait in ms (default 10000, max 60000).' },
|
|
636
|
+
},
|
|
637
|
+
output: {
|
|
638
|
+
schema: {
|
|
639
|
+
type: 'object',
|
|
640
|
+
additionalProperties: false,
|
|
641
|
+
properties: {
|
|
642
|
+
serial: { type: 'string', required: true },
|
|
643
|
+
text: { type: 'string', required: true },
|
|
644
|
+
mode: { type: 'string', required: true },
|
|
645
|
+
matched: { type: 'boolean', required: true },
|
|
646
|
+
waited_ms: { type: 'integer', required: true },
|
|
647
|
+
source: { type: 'string', required: true },
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
render: (_args, value) => {
|
|
651
|
+
const v = value ?? { serial: '', text: '', mode: 'appear', matched: false, waited_ms: 0, source: 'ui_tree' }
|
|
652
|
+
return [{ type: 'text', text: `${v.matched ? 'MATCHED' : 'TIMEOUT'}: "${v.text}" ${v.mode} (${v.source}) after ${v.waited_ms}ms on ${v.serial}` }]
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
async execute(args) {
|
|
656
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
657
|
+
const text = String(args.text ?? '').trim()
|
|
658
|
+
if (text === '') throw new Error('device_wait_for requires a non-empty text.')
|
|
659
|
+
const mode = args.mode === 'disappear' ? 'disappear' : 'appear'
|
|
660
|
+
const timeout = Math.min(Math.max(args.timeout_ms ?? 10_000, 500), 60_000)
|
|
661
|
+
const wanted = text.toLowerCase()
|
|
662
|
+
const start = Date.now()
|
|
663
|
+
const deadline = start + timeout
|
|
664
|
+
let source = 'ui_tree'
|
|
665
|
+
for (;;) {
|
|
666
|
+
let labels
|
|
667
|
+
try {
|
|
668
|
+
labels = UiTree.collectLabels((await UiTree.readUiTree(serial)).roots)
|
|
669
|
+
} catch {
|
|
670
|
+
labels = undefined
|
|
671
|
+
}
|
|
672
|
+
let present
|
|
673
|
+
if (labels !== undefined && labels.length > 0) {
|
|
674
|
+
source = 'ui_tree'
|
|
675
|
+
present = labels.some((label) => label.toLowerCase().includes(wanted))
|
|
676
|
+
} else {
|
|
677
|
+
const ocr = await ocrHasText(serial, wanted)
|
|
678
|
+
if (ocr === undefined) { source = 'ui_tree'; present = false }
|
|
679
|
+
else { source = 'ocr'; present = ocr }
|
|
680
|
+
}
|
|
681
|
+
const matched = mode === 'appear' ? present : !present
|
|
682
|
+
const waited = Math.min(timeout, Date.now() - start)
|
|
683
|
+
if (matched) return { serial, text, mode, matched: true, waited_ms: waited, source }
|
|
684
|
+
if (Date.now() >= deadline) return { serial, text, mode, matched: false, waited_ms: timeout, source }
|
|
685
|
+
await new Promise((resolve) => setTimeout(resolve, source === 'ocr' ? 2000 : 600))
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function deviceBootTool() {
|
|
692
|
+
return defineTool({
|
|
693
|
+
name: 'device_boot',
|
|
694
|
+
description: 'Boot an Android emulator AVD by name (from device_status.avds) and wait until it finishes booting. ' +
|
|
695
|
+
'If an emulator for that AVD is already running it is adopted. This is the device-centric boot — device_run is the ' +
|
|
696
|
+
'project-centric build+install+launch.',
|
|
697
|
+
parameters: {
|
|
698
|
+
avd: { type: 'string', description: 'AVD name to boot.' },
|
|
699
|
+
timeout_ms: { type: 'integer', description: 'Max wait for boot in ms (default 180000, max 600000).' },
|
|
700
|
+
},
|
|
701
|
+
output: {
|
|
702
|
+
schema: {
|
|
703
|
+
type: 'object',
|
|
704
|
+
additionalProperties: false,
|
|
705
|
+
properties: {
|
|
706
|
+
serial: { type: 'string', required: true },
|
|
707
|
+
avd: { type: 'string', required: true },
|
|
708
|
+
booted: { type: 'boolean', required: true },
|
|
709
|
+
alreadyRunning: { type: 'boolean', required: true },
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
render: (_args, value) => {
|
|
713
|
+
const v = value ?? { serial: '', avd: '', booted: false, alreadyRunning: false }
|
|
714
|
+
return [{ type: 'text', text: `${v.alreadyRunning ? 'Adopted running' : 'Booted'} emulator ${v.serial} (AVD ${v.avd}), boot completed: ${v.booted}` }]
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
async execute(args) {
|
|
718
|
+
const avd = String(args.avd ?? '').trim()
|
|
719
|
+
if (avd === '') throw new Error('device_boot requires an avd name (see device_status.avds).')
|
|
720
|
+
const timeout = Math.min(Math.max(args.timeout_ms ?? 180_000, 5_000), 600_000)
|
|
721
|
+
const rows = await DeviceBuild.devices()
|
|
722
|
+
for (const row of rows.filter((item) => item.state === 'device' && item.serial.startsWith('emulator-'))) {
|
|
723
|
+
const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
|
|
724
|
+
if (name === avd) {
|
|
725
|
+
const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
|
|
726
|
+
return { serial: row.serial, avd, booted, alreadyRunning: true }
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
|
|
730
|
+
const before = new Set(rows.map((item) => item.serial))
|
|
731
|
+
if (!DeviceBuild.bootEmulator(avd)) throw new Error(`Could not launch the emulator for AVD "${avd}".`)
|
|
732
|
+
const deadline = Date.now() + timeout
|
|
733
|
+
let serial
|
|
734
|
+
while (Date.now() < deadline) {
|
|
735
|
+
const now = await DeviceBuild.devices()
|
|
736
|
+
const fresh = now.find((item) => item.serial.startsWith('emulator-') && !before.has(item.serial))
|
|
737
|
+
if (fresh) { serial = fresh.serial; break }
|
|
738
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
739
|
+
}
|
|
740
|
+
if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
|
|
741
|
+
const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
|
|
742
|
+
if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
|
|
743
|
+
return { serial, avd, booted: true, alreadyRunning: false }
|
|
744
|
+
},
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function deviceShutdownTool() {
|
|
749
|
+
return defineTool({
|
|
750
|
+
name: 'device_shutdown',
|
|
751
|
+
description: 'Shut down an emulator (`adb emu kill`). Refuses physical devices — adb has no power-off verb for phones.',
|
|
752
|
+
parameters: {
|
|
753
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
754
|
+
},
|
|
755
|
+
output: {
|
|
756
|
+
schema: {
|
|
757
|
+
type: 'object',
|
|
758
|
+
additionalProperties: false,
|
|
759
|
+
properties: {
|
|
760
|
+
serial: { type: 'string', required: true },
|
|
761
|
+
shutdown: { type: 'boolean', required: true },
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
render: (_args, value) => [{ type: 'text', text: `Shut down emulator ${value?.serial}` }],
|
|
765
|
+
},
|
|
766
|
+
async execute(args) {
|
|
767
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
768
|
+
const isEmulator = serial.startsWith('emulator-') || (await DeviceBuild.avdName(serial).catch(() => undefined)) !== undefined
|
|
769
|
+
if (!isEmulator) {
|
|
770
|
+
throw new Error(`device_shutdown refuses ${serial}: it is a physical device and adb has no power-off verb for phones — use its own power button.`)
|
|
771
|
+
}
|
|
772
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'emu', 'kill']).exit
|
|
773
|
+
return { serial, shutdown: true }
|
|
774
|
+
},
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const DEVICE_ACTIONS = {
|
|
779
|
+
notifications: ['cmd', 'statusbar', 'expand-notifications'],
|
|
780
|
+
quick_settings: ['cmd', 'statusbar', 'expand-settings'],
|
|
781
|
+
collapse: ['cmd', 'statusbar', 'collapse'],
|
|
782
|
+
lock: ['input', 'keyevent', '223'],
|
|
783
|
+
wake: ['input', 'keyevent', '224'],
|
|
784
|
+
assistant: ['am', 'start', '-a', 'android.intent.action.ASSIST'],
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function deviceActionTool() {
|
|
788
|
+
return defineTool({
|
|
789
|
+
name: 'device_action',
|
|
790
|
+
description: 'Device-level actions beyond touches: open the notification shade or quick settings, collapse the shade, ' +
|
|
791
|
+
'lock or wake the screen, launch the assistant, or rotate the display (cycles 0→90→180→270 and pins auto-rotate off).',
|
|
792
|
+
parameters: {
|
|
793
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
794
|
+
action: { type: 'string', enum: [...Object.keys(DEVICE_ACTIONS), 'rotate'], description: 'Which device action to perform.' },
|
|
795
|
+
},
|
|
796
|
+
output: {
|
|
797
|
+
schema: {
|
|
798
|
+
type: 'object',
|
|
799
|
+
additionalProperties: false,
|
|
800
|
+
properties: {
|
|
801
|
+
serial: { type: 'string', required: true },
|
|
802
|
+
action: { type: 'string', required: true },
|
|
803
|
+
rotation: { type: 'integer' },
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
render: (_args, value) => [{ type: 'text', text: value?.rotation !== undefined ? `${value.action} on ${value.serial} → rotation ${value.rotation * 90}°` : `${value?.action} on ${value?.serial}` }],
|
|
807
|
+
},
|
|
808
|
+
async execute(args) {
|
|
809
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
810
|
+
const action = String(args.action ?? '')
|
|
811
|
+
if (action === 'rotate') {
|
|
812
|
+
const current = Number(await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'get', 'system', 'user_rotation']))
|
|
813
|
+
const next = ((Number.isFinite(current) ? current : 0) + 1) % 4
|
|
814
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']).exit
|
|
815
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'settings', 'put', 'system', 'user_rotation', String(next)]).exit
|
|
816
|
+
return { serial, action, rotation: next }
|
|
817
|
+
}
|
|
818
|
+
const shell = DEVICE_ACTIONS[action]
|
|
819
|
+
if (!shell) throw new Error(`unknown action "${action}" — use ${[...Object.keys(DEVICE_ACTIONS), 'rotate'].join(', ')}.`)
|
|
820
|
+
await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', ...shell]).exit
|
|
821
|
+
return { serial, action }
|
|
822
|
+
},
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function deviceAppsTool() {
|
|
827
|
+
return defineTool({
|
|
828
|
+
name: 'device_apps',
|
|
829
|
+
description: 'List installed Android packages (third-party by default; include_system=true adds platform apps). ' +
|
|
830
|
+
'Use it to find the real package name before device_launch_app — never guess one.',
|
|
831
|
+
parameters: {
|
|
832
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
833
|
+
include_system: { type: 'boolean', description: 'Include system/platform packages (default false).' },
|
|
834
|
+
},
|
|
835
|
+
output: {
|
|
836
|
+
schema: {
|
|
837
|
+
type: 'object',
|
|
838
|
+
additionalProperties: false,
|
|
839
|
+
properties: {
|
|
840
|
+
serial: { type: 'string', required: true },
|
|
841
|
+
count: { type: 'integer', required: true },
|
|
842
|
+
packages: { type: 'array', required: true, items: { type: 'string' } },
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
render: (_args, value) => {
|
|
846
|
+
const v = value ?? { serial: '', count: 0, packages: [] }
|
|
847
|
+
const shown = v.packages.slice(0, 60).join('\n ')
|
|
848
|
+
return [{ type: 'text', text: `${v.serial}: ${v.count} packages\n ${shown}${v.count > 60 ? `\n … and ${v.count - 60} more` : ''}` }]
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
async execute(args) {
|
|
852
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
853
|
+
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages', ...(args.include_system ? [] : ['-3'])])
|
|
854
|
+
const packages = output
|
|
855
|
+
.split(/\r?\n/)
|
|
856
|
+
.map((line) => line.trim())
|
|
857
|
+
.filter((line) => line.startsWith('package:'))
|
|
858
|
+
.map((line) => line.slice('package:'.length).trim())
|
|
859
|
+
return { serial, count: packages.length, packages: packages.slice(0, 200) }
|
|
860
|
+
},
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function deviceLaunchAppTool() {
|
|
865
|
+
return defineTool({
|
|
866
|
+
name: 'device_launch_app',
|
|
867
|
+
description: 'Launch an installed app by package name (or a unique substring of it). relaunch=true force-stops it first ' +
|
|
868
|
+
'for a cold start. Resolves the exact package via `pm list packages` so a wrong guess fails loudly instead of ' +
|
|
869
|
+
'opening the wrong app.',
|
|
870
|
+
parameters: {
|
|
871
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
872
|
+
package: { type: 'string', description: 'Package name or a unique substring of it.' },
|
|
873
|
+
relaunch: { type: 'boolean', description: 'Force-stop the app first (cold start).' },
|
|
874
|
+
},
|
|
875
|
+
output: {
|
|
876
|
+
schema: {
|
|
877
|
+
type: 'object',
|
|
878
|
+
additionalProperties: false,
|
|
879
|
+
properties: {
|
|
880
|
+
serial: { type: 'string', required: true },
|
|
881
|
+
package: { type: 'string', required: true },
|
|
882
|
+
launched: { type: 'boolean', required: true },
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
render: (_args, value) => [{ type: 'text', text: `Launched ${value?.package} on ${value?.serial}` }],
|
|
886
|
+
},
|
|
887
|
+
async execute(args) {
|
|
888
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
889
|
+
let pkg = String(args.package ?? '').trim()
|
|
890
|
+
if (pkg === '') throw new Error('device_launch_app requires a package name (or a unique substring).')
|
|
891
|
+
const listOut = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'pm', 'list', 'packages'])
|
|
892
|
+
const all = listOut
|
|
893
|
+
.split(/\r?\n/)
|
|
894
|
+
.map((line) => line.trim())
|
|
895
|
+
.filter((line) => line.startsWith('package:'))
|
|
896
|
+
.map((line) => line.slice('package:'.length).trim())
|
|
897
|
+
if (!all.includes(pkg)) {
|
|
898
|
+
const matches = all.filter((item) => item.toLowerCase().includes(pkg.toLowerCase()))
|
|
899
|
+
if (matches.length === 0) throw new Error(`No installed package matches "${pkg}". Run device_apps to list them.`)
|
|
900
|
+
if (matches.length > 1) throw new Error(`"${pkg}" matches ${matches.length} packages (${matches.slice(0, 8).join(', ')}${matches.length > 8 ? ', …' : ''}) — be more specific.`)
|
|
901
|
+
pkg = matches[0]
|
|
902
|
+
}
|
|
903
|
+
if (args.relaunch) await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'am', 'force-stop', pkg]).exit
|
|
904
|
+
const code = await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']).exit
|
|
905
|
+
if (code !== 0) throw new Error(`Could not launch ${pkg} (no launcher activity, or monkey failed with exit ${code}).`)
|
|
906
|
+
return { serial, package: pkg, launched: true }
|
|
907
|
+
},
|
|
908
|
+
})
|
|
909
|
+
}
|
|
910
|
+
|
|
604
911
|
function deviceScreenTool(engine) {
|
|
605
912
|
return defineTool({
|
|
606
913
|
name: 'device_screen',
|
|
@@ -715,7 +1022,9 @@ function deviceScreenTool(engine) {
|
|
|
715
1022
|
|
|
716
1023
|
async function captureScreenSize(serial) {
|
|
717
1024
|
const output = await DeviceBuild.capture(DeviceBuild.adb(), ['-s', serial, 'shell', 'wm', 'size'])
|
|
718
|
-
|
|
1025
|
+
// An `wm size` override wins over the physical panel — the input space is the override.
|
|
1026
|
+
const override = /Override size:\s*(\d+)x(\d+)/.exec(output)
|
|
1027
|
+
const match = override ?? /Physical size:\s*(\d+)x(\d+)/.exec(output)
|
|
719
1028
|
return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
|
|
720
1029
|
}
|
|
721
1030
|
|
|
@@ -1042,8 +1351,15 @@ function guidance() {
|
|
|
1042
1351
|
' text, and pixel bounds. Narrow with filter/max_depth; on a textless surface (WebView, Compose, canvas) use device_screen.',
|
|
1043
1352
|
'- device_tap_element: tap a control by resource_id or text/content-desc, with one-call verification via',
|
|
1044
1353
|
' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
|
|
1354
|
+
'- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
|
|
1355
|
+
' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
|
|
1045
1356
|
'- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
|
|
1046
|
-
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is
|
|
1357
|
+
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_tree → device_tap_element, falling back to',
|
|
1358
|
+
' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
|
|
1359
|
+
' (CJK, emoji) goes through the ADBKeyboard IME when installed, and is refused with the install hint otherwise.',
|
|
1360
|
+
'- device_action: notifications / quick_settings / collapse / lock / wake / assistant / rotate.',
|
|
1361
|
+
'- device_boot / device_shutdown: boot an AVD by name and wait for boot / shut an emulator down (refuses physical devices).',
|
|
1362
|
+
'- device_apps / device_launch_app: list installed packages (never guess a package name) / launch one by package or unique substring.',
|
|
1047
1363
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
1048
1364
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
1049
1365
|
'',
|
|
@@ -1113,6 +1429,12 @@ export function apply(ctx, config) {
|
|
|
1113
1429
|
deviceScreenTool(engine),
|
|
1114
1430
|
deviceUiTreeTool(),
|
|
1115
1431
|
deviceTapElementTool(),
|
|
1432
|
+
deviceWaitForTool(),
|
|
1433
|
+
deviceBootTool(),
|
|
1434
|
+
deviceShutdownTool(),
|
|
1435
|
+
deviceActionTool(),
|
|
1436
|
+
deviceAppsTool(),
|
|
1437
|
+
deviceLaunchAppTool(),
|
|
1116
1438
|
deviceLogTool(engine),
|
|
1117
1439
|
deviceStatusTool(engine),
|
|
1118
1440
|
deviceInputTool(),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|