@fortemate/vega-vvd-driver 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 +21 -0
- package/README.md +157 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +13 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +242 -0
- package/dist/console.d.ts +9 -0
- package/dist/console.js +87 -0
- package/dist/device.d.ts +48 -0
- package/dist/device.js +208 -0
- package/dist/discovery.d.ts +27 -0
- package/dist/discovery.js +164 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +10 -0
- package/dist/keys.d.ts +13 -0
- package/dist/keys.js +85 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +273 -0
- package/dist/png.d.ts +1 -0
- package/dist/png.js +49 -0
- package/dist/record.d.ts +19 -0
- package/dist/record.js +254 -0
- package/dist/safearea.d.ts +17 -0
- package/dist/safearea.js +70 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +63 -0
package/dist/device.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// A connection to one running Vega Virtual Device over the emulator's gRPC
|
|
2
|
+
// API (EmulatorController): remote keys, screenshots and the device's audio.
|
|
3
|
+
//
|
|
4
|
+
// Every call has a deadline, so a wedged emulator fails a command instead of
|
|
5
|
+
// hanging it, and every long operation takes an AbortSignal.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
8
|
+
import { credentials, loadPackageDefinition, Metadata, status, } from '@grpc/grpc-js';
|
|
9
|
+
import { loadSync } from '@grpc/proto-loader';
|
|
10
|
+
import { findEmulator, findProtoDirectory, } from "./discovery.js";
|
|
11
|
+
import { parseKeys } from "./keys.js";
|
|
12
|
+
// How long one call may take by default. A 1080p PNG takes about half a
|
|
13
|
+
// second on the VVD.
|
|
14
|
+
export const CALL_TIMEOUT_MS = 10_000;
|
|
15
|
+
// The device did not answer before a call's deadline.
|
|
16
|
+
export class TimeoutError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super('the Vega Virtual Device did not answer in time');
|
|
19
|
+
this.name = 'TimeoutError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class Device {
|
|
23
|
+
emulator;
|
|
24
|
+
#client;
|
|
25
|
+
#metadata;
|
|
26
|
+
#callTimeoutMs;
|
|
27
|
+
#streams = new Set();
|
|
28
|
+
constructor(emulator, client, metadata, callTimeoutMs) {
|
|
29
|
+
this.emulator = emulator;
|
|
30
|
+
this.#client = client;
|
|
31
|
+
this.#metadata = metadata;
|
|
32
|
+
this.#callTimeoutMs = callTimeoutMs;
|
|
33
|
+
}
|
|
34
|
+
// Connects to the newest running VVD with gRPC on, or to the given pid.
|
|
35
|
+
static connect(options = {}) {
|
|
36
|
+
const emulator = findEmulator(options);
|
|
37
|
+
const directory = options.protoDirectory ?? findProtoDirectory();
|
|
38
|
+
const definition = loadSync(join(directory, 'emulator_controller.proto'), {
|
|
39
|
+
includeDirs: [directory],
|
|
40
|
+
keepCase: true,
|
|
41
|
+
enums: String,
|
|
42
|
+
longs: String,
|
|
43
|
+
});
|
|
44
|
+
const control = loadPackageDefinition(definition).android.emulation.control;
|
|
45
|
+
const client = new control.EmulatorController(`127.0.0.1:${emulator.grpcPort}`, credentials.createInsecure(),
|
|
46
|
+
// A 1080p frame is 6 MB, above gRPC's default limit.
|
|
47
|
+
{ 'grpc.max_receive_message_length': 64 * 1024 * 1024 });
|
|
48
|
+
const metadata = new Metadata();
|
|
49
|
+
if (emulator.grpcToken)
|
|
50
|
+
metadata.add('authorization', `Bearer ${emulator.grpcToken}`);
|
|
51
|
+
return new Device(emulator, client, metadata, options.callTimeoutMs ?? CALL_TIMEOUT_MS);
|
|
52
|
+
}
|
|
53
|
+
// Ends the audio streams that are still open, then the connection.
|
|
54
|
+
close() {
|
|
55
|
+
for (const stream of this.#streams)
|
|
56
|
+
stream.cancel();
|
|
57
|
+
this.#streams.clear();
|
|
58
|
+
this.#client.close();
|
|
59
|
+
}
|
|
60
|
+
// One unary call, with a deadline, cancelled if the signal fires.
|
|
61
|
+
#call(start, { signal, deadline }) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const cancel = () => call.cancel();
|
|
65
|
+
const call = start({ deadline: deadline ?? Date.now() + this.#callTimeoutMs }, (error, reply) => {
|
|
66
|
+
signal?.removeEventListener('abort', cancel);
|
|
67
|
+
if (signal?.aborted)
|
|
68
|
+
reject(signal.reason);
|
|
69
|
+
else if (error?.code === status.DEADLINE_EXCEEDED)
|
|
70
|
+
reject(new TimeoutError());
|
|
71
|
+
else if (error)
|
|
72
|
+
reject(error);
|
|
73
|
+
else
|
|
74
|
+
resolve(reply);
|
|
75
|
+
});
|
|
76
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
#sendKey(code, eventType, options = {}) {
|
|
80
|
+
return this.#call((callOptions, callback) => this.#client.sendKey({ codeType: 'Evdev', eventType, keyCode: code }, this.#metadata, callOptions, callback), options);
|
|
81
|
+
}
|
|
82
|
+
// Presses keys in order. Accepts names such as `ok`, `down*3`, `ok:down`,
|
|
83
|
+
// or steps already parsed. A cancelled press still releases its key.
|
|
84
|
+
async press(keys, options = {}) {
|
|
85
|
+
const steps = typeof keys[0] === 'string'
|
|
86
|
+
? parseKeys(keys)
|
|
87
|
+
: keys;
|
|
88
|
+
const { signal } = options;
|
|
89
|
+
const gap = options.gapMs ?? 450;
|
|
90
|
+
const hold = options.holdMs ?? 90;
|
|
91
|
+
for (const step of steps) {
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
if (step.half === 'press') {
|
|
94
|
+
let released = false;
|
|
95
|
+
try {
|
|
96
|
+
await this.#sendKey(step.code, 'keydown', { signal });
|
|
97
|
+
await sleep(hold, undefined, { signal });
|
|
98
|
+
await this.#sendKey(step.code, 'keyup');
|
|
99
|
+
released = true;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (!released)
|
|
103
|
+
await this.#sendKey(step.code, 'keyup').catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await this.#sendKey(step.code, step.half === 'down' ? 'keydown' : 'keyup', { signal });
|
|
108
|
+
}
|
|
109
|
+
await sleep(gap, undefined, { signal });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// One frame of the screen, as RGB888 or as a PNG file.
|
|
113
|
+
async screenshot(format = 'png', options = {}) {
|
|
114
|
+
const reply = await this.#call((callOptions, callback) => this.#client.getScreenshot({ format: format === 'png' ? 'PNG' : 'RGB888' }, this.#metadata, callOptions, callback), options);
|
|
115
|
+
const data = reply.image ?? Buffer.alloc(0);
|
|
116
|
+
if (data.length === 0)
|
|
117
|
+
throw new Error('the device returned an empty screen: is its display on?');
|
|
118
|
+
return {
|
|
119
|
+
width: reply.format?.width ?? 0,
|
|
120
|
+
height: reply.format?.height ?? 0,
|
|
121
|
+
data,
|
|
122
|
+
timestampUs: Number(reply.timestampUs ?? 0),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// A screenshot taken while polling until `endMs`: its deadline stays close
|
|
126
|
+
// to the end, and one that runs out after the end gives undefined.
|
|
127
|
+
async #poll(endMs, signal) {
|
|
128
|
+
try {
|
|
129
|
+
return await this.screenshot('rgb', {
|
|
130
|
+
signal,
|
|
131
|
+
deadline: Math.min(Date.now() + this.#callTimeoutMs, endMs + 1000),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof TimeoutError && Date.now() >= endMs)
|
|
136
|
+
return undefined;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Waits until the screen differs from how it looked when the wait began.
|
|
141
|
+
// Polls screenshots: the emulator's own screenshot stream can stop
|
|
142
|
+
// delivering frames while the screen keeps changing.
|
|
143
|
+
async waitForChange(options = {}) {
|
|
144
|
+
const { signal } = options;
|
|
145
|
+
const endMs = Date.now() + (options.timeoutMs ?? 5000);
|
|
146
|
+
const first = await this.screenshot('rgb', { signal });
|
|
147
|
+
while (Date.now() < endMs) {
|
|
148
|
+
await sleep(options.intervalMs ?? 50, undefined, { signal });
|
|
149
|
+
const frame = await this.#poll(endMs, signal);
|
|
150
|
+
if (!frame)
|
|
151
|
+
return false;
|
|
152
|
+
if (!frame.data.equals(first.data))
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// Calls `onFrame` with every distinct frame for `durationMs`. Useful to
|
|
158
|
+
// check an animation: a 220 ms slide comes through as about ten frames.
|
|
159
|
+
async frames(durationMs, onFrame, options = {}) {
|
|
160
|
+
const { signal } = options;
|
|
161
|
+
const endMs = Date.now() + durationMs;
|
|
162
|
+
let previous;
|
|
163
|
+
let count = 0;
|
|
164
|
+
while (Date.now() < endMs) {
|
|
165
|
+
const frame = await this.#poll(endMs, signal);
|
|
166
|
+
if (!frame)
|
|
167
|
+
break;
|
|
168
|
+
if (!previous || !frame.data.equals(previous)) {
|
|
169
|
+
previous = frame.data;
|
|
170
|
+
count += 1;
|
|
171
|
+
await onFrame(frame);
|
|
172
|
+
}
|
|
173
|
+
if (options.intervalMs)
|
|
174
|
+
await sleep(options.intervalMs, undefined, { signal });
|
|
175
|
+
}
|
|
176
|
+
return count;
|
|
177
|
+
}
|
|
178
|
+
// The device's audio as 16-bit little-endian stereo PCM at 44.1 kHz.
|
|
179
|
+
// Returns a function that stops the stream and gives back the packets that
|
|
180
|
+
// arrived, each with the time it was captured; calling it again gives the
|
|
181
|
+
// same packets. The emulator sends nothing while the device is silent, so
|
|
182
|
+
// the packets can have gaps; see assembleAudio in record.ts.
|
|
183
|
+
listen() {
|
|
184
|
+
const packets = [];
|
|
185
|
+
const stream = this.#client.streamAudio({
|
|
186
|
+
samplingRate: 44100,
|
|
187
|
+
channels: 'Stereo',
|
|
188
|
+
format: 'AUD_FMT_S16',
|
|
189
|
+
mode: 'MODE_REAL_TIME',
|
|
190
|
+
}, this.#metadata);
|
|
191
|
+
this.#streams.add(stream);
|
|
192
|
+
stream.on('data', (packet) => {
|
|
193
|
+
if (packet.audio?.length)
|
|
194
|
+
packets.push({
|
|
195
|
+
timestampUs: Number(packet.timestamp ?? 0),
|
|
196
|
+
pcm: packet.audio,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
stream.on('error', () => {
|
|
200
|
+
// Cancelling the stream ends it with an error; nothing to report.
|
|
201
|
+
});
|
|
202
|
+
return () => {
|
|
203
|
+
if (this.#streams.delete(stream))
|
|
204
|
+
stream.cancel();
|
|
205
|
+
return packets;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Emulator = {
|
|
2
|
+
pid: number;
|
|
3
|
+
file: string;
|
|
4
|
+
grpcPort: number;
|
|
5
|
+
readonly grpcToken: string | undefined;
|
|
6
|
+
consolePort: number | undefined;
|
|
7
|
+
avdName: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
export type SystemFacts = {
|
|
10
|
+
uid: number | undefined;
|
|
11
|
+
user: string | undefined;
|
|
12
|
+
tmp: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const runningDirectories: (env?: NodeJS.ProcessEnv, home?: string, system?: SystemFacts) => string[];
|
|
15
|
+
export declare const parseDiscovery: (text: string) => Map<string, string>;
|
|
16
|
+
export type FindOptions = {
|
|
17
|
+
directories?: string[];
|
|
18
|
+
pid?: number;
|
|
19
|
+
alive?: (pid: number) => boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare const findEmulators: (options?: FindOptions) => Emulator[];
|
|
22
|
+
export declare class NoDeviceError extends Error {
|
|
23
|
+
constructor();
|
|
24
|
+
}
|
|
25
|
+
export declare const findEmulator: (options?: FindOptions) => Emulator;
|
|
26
|
+
export declare const findProtoDirectory: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
27
|
+
export declare const consoleTokenFile: (home?: string) => string;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Finding a running Vega Virtual Device. The VVD is built on the Android
|
|
2
|
+
// emulator, and a running emulator whose gRPC endpoint is on advertises itself
|
|
3
|
+
// in a discovery file, `pid_<pid>.ini`, in a per-user directory. The file holds
|
|
4
|
+
// the gRPC port and an access token; the token is read here and never printed.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
const userName = () => {
|
|
9
|
+
try {
|
|
10
|
+
return userInfo().username;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined; // no entry in the user database
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const systemFacts = () => ({
|
|
17
|
+
uid: process.getuid?.(),
|
|
18
|
+
user: userName(),
|
|
19
|
+
tmp: tmpdir(),
|
|
20
|
+
});
|
|
21
|
+
// Where a running emulator leaves its discovery file. The macOS location was
|
|
22
|
+
// measured with the VVD. The Linux ones are untested with a VVD: the runtime
|
|
23
|
+
// directory, then the Android emulator's fallbacks.
|
|
24
|
+
export const runningDirectories = (env = process.env, home = homedir(), system = systemFacts()) => {
|
|
25
|
+
const { uid, user, tmp } = system;
|
|
26
|
+
return [
|
|
27
|
+
...new Set([
|
|
28
|
+
join(home, 'Library/Caches/TemporaryItems/avd/running'),
|
|
29
|
+
env.XDG_RUNTIME_DIR ? join(env.XDG_RUNTIME_DIR, 'avd/running') : '',
|
|
30
|
+
uid === undefined ? '' : `/run/user/${uid}/avd/running`,
|
|
31
|
+
user ? `/tmp/android-${user}/avd/running` : '',
|
|
32
|
+
user ? join(tmp, `android-${user}`, 'avd/running') : '',
|
|
33
|
+
join(home, '.android/avd/running'),
|
|
34
|
+
].filter(Boolean)),
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
// A discovery file is `key=value` lines.
|
|
38
|
+
export const parseDiscovery = (text) => {
|
|
39
|
+
const fields = new Map();
|
|
40
|
+
for (const line of text.split(/\r?\n/)) {
|
|
41
|
+
const at = line.indexOf('=');
|
|
42
|
+
if (at > 0)
|
|
43
|
+
fields.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
|
|
44
|
+
}
|
|
45
|
+
return fields;
|
|
46
|
+
};
|
|
47
|
+
const isAlive = (pid) => {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// EPERM: the process exists but belongs to someone else.
|
|
54
|
+
return error.code === 'EPERM';
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const toPort = (value) => {
|
|
58
|
+
const port = Number(value);
|
|
59
|
+
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
60
|
+
};
|
|
61
|
+
// The running emulators with gRPC on, newest first. Stale files left by an
|
|
62
|
+
// emulator that has exited are skipped.
|
|
63
|
+
export const findEmulators = (options = {}) => {
|
|
64
|
+
const alive = options.alive ?? isAlive;
|
|
65
|
+
const found = [];
|
|
66
|
+
for (const directory of options.directories ?? runningDirectories()) {
|
|
67
|
+
if (!existsSync(directory))
|
|
68
|
+
continue;
|
|
69
|
+
for (const name of readdirSync(directory)) {
|
|
70
|
+
const match = /^pid_(\d+)\.ini$/.exec(name);
|
|
71
|
+
if (!match)
|
|
72
|
+
continue;
|
|
73
|
+
const pid = Number(match[1]);
|
|
74
|
+
if (options.pid !== undefined && pid !== options.pid)
|
|
75
|
+
continue;
|
|
76
|
+
if (!alive(pid))
|
|
77
|
+
continue;
|
|
78
|
+
const file = join(directory, name);
|
|
79
|
+
const fields = parseDiscovery(readFileSync(file, 'utf8'));
|
|
80
|
+
const grpcPort = toPort(fields.get('grpc.port'));
|
|
81
|
+
if (grpcPort === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
const emulator = Object.defineProperty({
|
|
84
|
+
pid,
|
|
85
|
+
file,
|
|
86
|
+
grpcPort,
|
|
87
|
+
consolePort: toPort(fields.get('port.serial')),
|
|
88
|
+
avdName: fields.get('avd.name') || undefined,
|
|
89
|
+
}, 'grpcToken', { value: fields.get('grpc.token') || undefined, enumerable: false });
|
|
90
|
+
found.push({ emulator, modified: statSync(file).mtimeMs });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found
|
|
94
|
+
.sort((a, b) => b.modified - a.modified)
|
|
95
|
+
.map(({ emulator }) => emulator);
|
|
96
|
+
};
|
|
97
|
+
export class NoDeviceError extends Error {
|
|
98
|
+
constructor() {
|
|
99
|
+
super('No running Vega Virtual Device with gRPC found. Start it (vega virtual-device start), ' +
|
|
100
|
+
'then turn gRPC on with `vvd enable-grpc`: it is off after every start of the device.');
|
|
101
|
+
this.name = 'NoDeviceError';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// The one emulator to drive: the given pid, or the newest.
|
|
105
|
+
export const findEmulator = (options = {}) => {
|
|
106
|
+
const [emulator] = findEmulators(options);
|
|
107
|
+
if (!emulator)
|
|
108
|
+
throw new NoDeviceError();
|
|
109
|
+
return emulator;
|
|
110
|
+
};
|
|
111
|
+
// The emulator's gRPC API is described by emulator_controller.proto, which
|
|
112
|
+
// ships inside the Vega SDK (it is AOSP code under Apache-2.0). It is loaded
|
|
113
|
+
// from the developer's own SDK rather than copied here. VVD_PROTO_DIR
|
|
114
|
+
// overrides the search.
|
|
115
|
+
const inVersion = (versionDir) => join(versionDir, 'vvd/images/tv/vmtools/agent/lib');
|
|
116
|
+
const hasProto = (dir) => existsSync(join(dir, 'emulator_controller.proto'));
|
|
117
|
+
// The SDK's own record of where it lives and which version is the default.
|
|
118
|
+
const configuredVersion = (home) => {
|
|
119
|
+
try {
|
|
120
|
+
const config = JSON.parse(readFileSync(join(home, 'vega/config.json'), 'utf8'));
|
|
121
|
+
const [channel, version] = (config.defaultVersion ?? '').split('@');
|
|
122
|
+
return config.sdkPath && channel && version
|
|
123
|
+
? join(config.sdkPath, 'vega-sdk', channel, version)
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined; // no config, or an unreadable one: scan instead
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
// Directories only: a stray file, such as Finder's .DS_Store, is skipped.
|
|
131
|
+
const subdirectories = (dir) => {
|
|
132
|
+
try {
|
|
133
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
134
|
+
.filter((entry) => entry.isDirectory() ||
|
|
135
|
+
(entry.isSymbolicLink() &&
|
|
136
|
+
statSync(join(dir, entry.name), {
|
|
137
|
+
throwIfNoEntry: false,
|
|
138
|
+
})?.isDirectory() === true))
|
|
139
|
+
.map((entry) => entry.name);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
// Every installed version, newest first by number: 0.24 comes before 0.9.
|
|
146
|
+
const installedVersions = (sdkRoot) => subdirectories(sdkRoot).flatMap((channel) => subdirectories(join(sdkRoot, channel))
|
|
147
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
|
148
|
+
.map((version) => join(sdkRoot, channel, version)));
|
|
149
|
+
export const findProtoDirectory = (env = process.env, home = homedir()) => {
|
|
150
|
+
if (env.VVD_PROTO_DIR)
|
|
151
|
+
return env.VVD_PROTO_DIR;
|
|
152
|
+
const configured = configuredVersion(home);
|
|
153
|
+
if (configured && hasProto(inVersion(configured)))
|
|
154
|
+
return inVersion(configured);
|
|
155
|
+
const found = installedVersions(join(home, 'vega/sdk/vega-sdk'))
|
|
156
|
+
.map(inVersion)
|
|
157
|
+
.find(hasProto);
|
|
158
|
+
if (!found)
|
|
159
|
+
throw new Error('emulator_controller.proto not found in the Vega SDK. Install the SDK, or set VVD_PROTO_DIR ' +
|
|
160
|
+
'to the directory that holds it.');
|
|
161
|
+
return found;
|
|
162
|
+
};
|
|
163
|
+
// The emulator console's token, which authenticates `auth <token>`.
|
|
164
|
+
export const consoleTokenFile = (home = homedir()) => join(home, '.emulator_console_auth_token');
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { CALL_TIMEOUT_MS, Device, TimeoutError } from './device.ts';
|
|
2
|
+
export type { AudioPacket, CallOptions, ConnectOptions, Frame, PressOptions, } from './device.ts';
|
|
3
|
+
export { findEmulator, findEmulators, findProtoDirectory, NoDeviceError, parseDiscovery, runningDirectories, } from './discovery.ts';
|
|
4
|
+
export type { Emulator, FindOptions, SystemFacts } from './discovery.ts';
|
|
5
|
+
export { enableGrpc, isConsolePort, runConsole } from './console.ts';
|
|
6
|
+
export type { ConsoleOptions } from './console.ts';
|
|
7
|
+
export { KEY_CODES, KeyError, parseKey, parseKeys, REMOTE_KEYS, } from './keys.ts';
|
|
8
|
+
export type { Half, KeyStep } from './keys.ts';
|
|
9
|
+
export { assembleAudio, audioWithin, hasFfmpeg, record } from './record.ts';
|
|
10
|
+
export type { RecordOptions, RecordResult } from './record.ts';
|
|
11
|
+
export { checkSafeArea, formatColour, parseColour } from './safearea.ts';
|
|
12
|
+
export type { Rgb, SafeAreaOptions, SafeAreaReport } from './safearea.ts';
|
|
13
|
+
export { encodePng } from './png.ts';
|
|
14
|
+
export { createServer, MAX_PRESSES, serveStdio, videoPath } from './mcp.ts';
|
|
15
|
+
export type { ServerOptions } from './mcp.ts';
|
|
16
|
+
export { VERSION } from './version.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The library: everything the `vvd` command and the MCP server are built from.
|
|
2
|
+
export { CALL_TIMEOUT_MS, Device, TimeoutError } from "./device.js";
|
|
3
|
+
export { findEmulator, findEmulators, findProtoDirectory, NoDeviceError, parseDiscovery, runningDirectories, } from "./discovery.js";
|
|
4
|
+
export { enableGrpc, isConsolePort, runConsole } from "./console.js";
|
|
5
|
+
export { KEY_CODES, KeyError, parseKey, parseKeys, REMOTE_KEYS, } from "./keys.js";
|
|
6
|
+
export { assembleAudio, audioWithin, hasFfmpeg, record } from "./record.js";
|
|
7
|
+
export { checkSafeArea, formatColour, parseColour } from "./safearea.js";
|
|
8
|
+
export { encodePng } from "./png.js";
|
|
9
|
+
export { createServer, MAX_PRESSES, serveStdio, videoPath } from "./mcp.js";
|
|
10
|
+
export { VERSION } from "./version.js";
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const KEY_CODES: Readonly<Record<string, number>>;
|
|
2
|
+
export declare const REMOTE_KEYS: Readonly<Record<string, string>>;
|
|
3
|
+
export type Half = 'press' | 'down' | 'up';
|
|
4
|
+
export type KeyStep = {
|
|
5
|
+
name: string;
|
|
6
|
+
code: number;
|
|
7
|
+
half: Half;
|
|
8
|
+
};
|
|
9
|
+
export declare class KeyError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare const parseKey: (argument: string) => KeyStep[];
|
|
13
|
+
export declare const parseKeys: (argumentsList: readonly string[]) => KeyStep[];
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Remote keys by name. The VVD takes Linux evdev key codes through the
|
|
2
|
+
// emulator's gRPC `sendKey`, the path its on-screen remote uses.
|
|
3
|
+
//
|
|
4
|
+
// Measured on the VVD (Vega SDK 0.24):
|
|
5
|
+
// - OK is KEY_KPENTER, which is what the on-screen remote sends; apps receive
|
|
6
|
+
// it as `kpenter`, not the `select` that the remote documentation names.
|
|
7
|
+
// KEY_SELECT and KEY_OK are not declared by the emulator's virtual keyboard,
|
|
8
|
+
// so they never arrive.
|
|
9
|
+
// - Back is KEY_BACK. KEY_ESC does not reach an app as Back over gRPC.
|
|
10
|
+
export const KEY_CODES = {
|
|
11
|
+
KEY_ESC: 1,
|
|
12
|
+
KEY_ENTER: 28,
|
|
13
|
+
KEY_F1: 59,
|
|
14
|
+
KEY_F2: 60,
|
|
15
|
+
KEY_F3: 61,
|
|
16
|
+
KEY_F4: 62,
|
|
17
|
+
KEY_F5: 63,
|
|
18
|
+
KEY_KPENTER: 96,
|
|
19
|
+
KEY_UP: 103,
|
|
20
|
+
KEY_LEFT: 105,
|
|
21
|
+
KEY_RIGHT: 106,
|
|
22
|
+
KEY_DOWN: 108,
|
|
23
|
+
KEY_MENU: 139,
|
|
24
|
+
KEY_BACK: 158,
|
|
25
|
+
KEY_PLAYPAUSE: 164,
|
|
26
|
+
KEY_REWIND: 168,
|
|
27
|
+
KEY_HOMEPAGE: 172,
|
|
28
|
+
KEY_FASTFORWARD: 208,
|
|
29
|
+
};
|
|
30
|
+
// The remote's buttons, by the names a script would use.
|
|
31
|
+
export const REMOTE_KEYS = {
|
|
32
|
+
up: 'KEY_UP',
|
|
33
|
+
down: 'KEY_DOWN',
|
|
34
|
+
left: 'KEY_LEFT',
|
|
35
|
+
right: 'KEY_RIGHT',
|
|
36
|
+
ok: 'KEY_KPENTER',
|
|
37
|
+
select: 'KEY_KPENTER',
|
|
38
|
+
back: 'KEY_BACK',
|
|
39
|
+
menu: 'KEY_MENU',
|
|
40
|
+
playpause: 'KEY_PLAYPAUSE',
|
|
41
|
+
rewind: 'KEY_REWIND',
|
|
42
|
+
fastforward: 'KEY_FASTFORWARD',
|
|
43
|
+
};
|
|
44
|
+
export class KeyError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'KeyError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Own properties only: `constructor` or `__proto__` must not pass for a key.
|
|
51
|
+
const codeOf = (name) => {
|
|
52
|
+
const lower = name.toLowerCase();
|
|
53
|
+
if (Object.hasOwn(REMOTE_KEYS, lower))
|
|
54
|
+
return KEY_CODES[REMOTE_KEYS[lower]];
|
|
55
|
+
const upper = name.toUpperCase();
|
|
56
|
+
if (Object.hasOwn(KEY_CODES, upper))
|
|
57
|
+
return KEY_CODES[upper];
|
|
58
|
+
if (/^\d+$/.test(name)) {
|
|
59
|
+
const code = Number(name);
|
|
60
|
+
if (code > 0 && code < 0x300)
|
|
61
|
+
return code;
|
|
62
|
+
}
|
|
63
|
+
throw new KeyError(`unknown key "${name}": use ${Object.keys(REMOTE_KEYS).join(', ')}, a KEY_* name or an evdev code`);
|
|
64
|
+
};
|
|
65
|
+
// Parses one key argument:
|
|
66
|
+
// ok press and release
|
|
67
|
+
// ok:down only the press, to hold a key
|
|
68
|
+
// ok:up only the release
|
|
69
|
+
// down*3 press three times
|
|
70
|
+
export const parseKey = (argument) => {
|
|
71
|
+
const match = /^([^:*]+)(?::(down|up))?(?:\*(\d+))?$/i.exec(argument.trim());
|
|
72
|
+
if (!match)
|
|
73
|
+
throw new KeyError(`cannot read key "${argument}"`);
|
|
74
|
+
const [, name, half, times] = match;
|
|
75
|
+
const count = times === undefined ? 1 : Number(times);
|
|
76
|
+
if (count < 1 || count > 100)
|
|
77
|
+
throw new KeyError(`repeat count in "${argument}" must be 1 to 100`);
|
|
78
|
+
const step = {
|
|
79
|
+
name,
|
|
80
|
+
code: codeOf(name),
|
|
81
|
+
half: half?.toLowerCase() ?? 'press',
|
|
82
|
+
};
|
|
83
|
+
return Array.from({ length: count }, () => step);
|
|
84
|
+
};
|
|
85
|
+
export const parseKeys = (argumentsList) => argumentsList.flatMap(parseKey);
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare const MAX_PRESSES = 100;
|
|
3
|
+
export type ServerOptions = {
|
|
4
|
+
pid?: number;
|
|
5
|
+
directories?: string[];
|
|
6
|
+
protoDirectory?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const videoPath: (file: string, overwrite?: boolean) => string;
|
|
9
|
+
export declare const createServer: (options?: ServerOptions) => McpServer;
|
|
10
|
+
export declare const serveStdio: (options?: ServerOptions) => Promise<void>;
|