dsh-mobilecode 0.7.1 → 0.8.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 +58 -2
- package/lib/device-build.js +121 -0
- package/lib/index.js +749 -1
- package/lib/mesh-hub.js +343 -0
- package/lib/vision.js +9 -6
- package/package.json +3 -2
- package/sdk/mesh-client.mjs +78 -0
package/lib/mesh-hub.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// mesh-hub.js — host-mediated LocalSend-style mesh for co-op device testing.
|
|
2
|
+
//
|
|
3
|
+
// Two Android emulators cannot multicast-discover each other (each sits behind
|
|
4
|
+
// its own slirp NAT), but both can reach the host at 10.0.2.2. So the hub runs
|
|
5
|
+
// on the host: peers join with a persistent random callsign, link into a
|
|
6
|
+
// session, and exchange JSON messages through it. Every message is logged
|
|
7
|
+
// (with drop/dup marks) and every delivery is governed by a per-session policy
|
|
8
|
+
// (latency, jitter, drop, duplicate, bandwidth throttle) — the same hub is
|
|
9
|
+
// the game's network backbone AND the agent's observation window.
|
|
10
|
+
//
|
|
11
|
+
// Pure module: clock and RNG are injectable so the whole queue/policy engine
|
|
12
|
+
// is unit-testable offline (test/mesh-hub.mjs).
|
|
13
|
+
|
|
14
|
+
import { createHmac, randomBytes } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
const ADJECTIVES = [
|
|
17
|
+
'amber', 'brisk', 'cobalt', 'dusky', 'eager', 'fable', 'gentle', 'hollow',
|
|
18
|
+
'ivory', 'jolly', 'keen', 'lunar', 'mellow', 'noble', 'onyx', 'prism',
|
|
19
|
+
'quiet', 'rapid', 'solar', 'tidal', 'umber', 'vivid', 'warm', 'zesty',
|
|
20
|
+
'bold', 'crisp', 'dandy', 'eery', 'fuzzy', 'gilded', 'happy', 'mystic',
|
|
21
|
+
]
|
|
22
|
+
const ANIMALS = [
|
|
23
|
+
'fox', 'owl', 'hare', 'bear', 'lynx', 'wren', 'moth', 'toad',
|
|
24
|
+
'crow', 'deer', 'goat', 'kiwi', 'newt', 'puma', 'quail', 'seal',
|
|
25
|
+
'tern', 'vole', 'wasp', 'yak', 'zebu', 'crane', 'finch', 'gecko',
|
|
26
|
+
'heron', 'ibis', 'jackal', 'koala', 'lemur', 'narwhal', 'otter', 'panda',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
function fnv1a(text) {
|
|
30
|
+
let h = 0x811c9dc5
|
|
31
|
+
for (let i = 0; i < text.length; i++) {
|
|
32
|
+
h ^= text.charCodeAt(i)
|
|
33
|
+
h = Math.imul(h, 0x01000193)
|
|
34
|
+
}
|
|
35
|
+
return h >>> 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hex16(text) {
|
|
39
|
+
return fnv1a(text).toString(16).padStart(8, '0') + fnv1a(text + '#2').toString(16).padStart(8, '0')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Deterministic LocalSend-style callsign for a stable seed (e.g. a serial). */
|
|
43
|
+
export function callsign(seed) {
|
|
44
|
+
const h = fnv1a('mesh-name:' + seed)
|
|
45
|
+
return ADJECTIVES[h % ADJECTIVES.length] + '-' + ANIMALS[(h >>> 5) % ANIMALS.length]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
|
49
|
+
const num = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
|
|
50
|
+
|
|
51
|
+
/** Per-session network-condition policy. All knobs default to OFF. */
|
|
52
|
+
function makePolicy(input) {
|
|
53
|
+
return {
|
|
54
|
+
latencyMs: clamp(Math.round(num(input?.latencyMs, 0)), 0, 5000),
|
|
55
|
+
jitterMs: clamp(Math.round(num(input?.jitterMs, 0)), 0, 5000),
|
|
56
|
+
dropPct: clamp(num(input?.dropPct, 0), 0, 100),
|
|
57
|
+
dupPct: clamp(num(input?.dupPct, 0), 0, 100),
|
|
58
|
+
throttleKbps: clamp(Math.round(num(input?.throttleKbps, 0)), 0, 1_000_000),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MeshHub {
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {Buffer|string} [opts.secret] token signing key (per-process by default)
|
|
66
|
+
* @param {() => number} [opts.now] injectable clock (ms)
|
|
67
|
+
* @param {() => number} [opts.random] injectable RNG 0..1
|
|
68
|
+
* @param {number} [opts.maxLog] log ring size
|
|
69
|
+
*/
|
|
70
|
+
constructor(opts = {}) {
|
|
71
|
+
this.secret = opts.secret ?? randomBytes(32)
|
|
72
|
+
this.now = opts.now ?? (() => Date.now())
|
|
73
|
+
this.random = opts.random ?? Math.random
|
|
74
|
+
this.maxLog = opts.maxLog ?? 500
|
|
75
|
+
this.peers = new Map() // id -> {id,name,serial,role,joinedAt,lastSeen}
|
|
76
|
+
this.sessions = new Map() // id -> {id,members:[peerId],policy,createdAt}
|
|
77
|
+
this.inboxes = new Map() // peerId -> envelope[] (kept seq-sorted by push order)
|
|
78
|
+
this.entries = [] // log ring
|
|
79
|
+
this.seq = 0
|
|
80
|
+
this.messageSeq = 0
|
|
81
|
+
this.waiters = [] // long-poll resolvers
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- auth ---------------------------------------------------------------
|
|
85
|
+
// Tokens are HMAC(process-secret, peerId): stable while the host lives,
|
|
86
|
+
// invalid after a restart — join() is idempotent per serial, so a client
|
|
87
|
+
// that kept an old token simply re-joins and gets a fresh one.
|
|
88
|
+
tokenFor(id) {
|
|
89
|
+
return createHmac('sha256', this.secret).update('mesh:' + id).digest('base64url').slice(0, 32)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
verify(id, token) {
|
|
93
|
+
if (typeof id !== 'string' || typeof token !== 'string') return false
|
|
94
|
+
const expected = this.tokenFor(id)
|
|
95
|
+
if (token.length !== expected.length) return false
|
|
96
|
+
let diff = 0
|
|
97
|
+
for (let i = 0; i < expected.length; i++) diff |= token.charCodeAt(i) ^ expected.charCodeAt(i)
|
|
98
|
+
return diff === 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- identity -----------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Join (or re-join) the mesh. A serial pins a stable identity: same device,
|
|
104
|
+
* same peer id and same callsign forever. Returns {peer, token}.
|
|
105
|
+
*/
|
|
106
|
+
join({ serial, name, role } = {}) {
|
|
107
|
+
const cleanSerial = typeof serial === 'string' && serial.trim() !== '' ? serial.trim() : undefined
|
|
108
|
+
if (cleanSerial) {
|
|
109
|
+
const existing = [...this.peers.values()].find((peer) => peer.serial === cleanSerial)
|
|
110
|
+
if (existing) {
|
|
111
|
+
existing.lastSeen = this.now()
|
|
112
|
+
return { peer: existing, token: this.tokenFor(existing.id) }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const id = cleanSerial ? 'p-' + hex16('serial:' + cleanSerial) : 'p-' + randomBytes(6).toString('hex')
|
|
116
|
+
const wanted = typeof name === 'string' && /^[A-Za-z0-9 _-]{1,32}$/.test(name.trim())
|
|
117
|
+
? name.trim().replace(/\s+/g, '-')
|
|
118
|
+
: callsign(cleanSerial ?? id)
|
|
119
|
+
const taken = new Set([...this.peers.values()].map((peer) => peer.name))
|
|
120
|
+
let unique = wanted
|
|
121
|
+
for (let n = 2; taken.has(unique); n++) unique = wanted + '-' + n
|
|
122
|
+
const peer = {
|
|
123
|
+
id,
|
|
124
|
+
name: unique,
|
|
125
|
+
serial: cleanSerial,
|
|
126
|
+
role: typeof role === 'string' && role ? role : 'player',
|
|
127
|
+
joinedAt: this.now(),
|
|
128
|
+
lastSeen: this.now(),
|
|
129
|
+
}
|
|
130
|
+
this.peers.set(id, peer)
|
|
131
|
+
this.inboxes.set(id, [])
|
|
132
|
+
this.#log('join', { peer: peer.name, id, serial: cleanSerial })
|
|
133
|
+
return { peer, token: this.tokenFor(id) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
leave(id) {
|
|
137
|
+
const peer = this.peers.get(id)
|
|
138
|
+
if (!peer) return false
|
|
139
|
+
for (const session of this.sessions.values()) {
|
|
140
|
+
session.members = session.members.filter((member) => member !== id)
|
|
141
|
+
if (session.members.length < 2) this.sessions.delete(session.id)
|
|
142
|
+
}
|
|
143
|
+
this.peers.delete(id)
|
|
144
|
+
this.inboxes.delete(id)
|
|
145
|
+
this.#log('leave', { peer: peer.name, id })
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a peer by id, callsign (case-insensitive), or serial. */
|
|
150
|
+
resolve(query) {
|
|
151
|
+
if (typeof query !== 'string' || query === '') return undefined
|
|
152
|
+
const direct = this.peers.get(query)
|
|
153
|
+
if (direct) return direct
|
|
154
|
+
const lower = query.toLowerCase()
|
|
155
|
+
return [...this.peers.values()].find(
|
|
156
|
+
(peer) => peer.name.toLowerCase() === lower || peer.serial === query,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- sessions -----------------------------------------------------------
|
|
161
|
+
link(queries) {
|
|
162
|
+
const members = []
|
|
163
|
+
for (const query of queries ?? []) {
|
|
164
|
+
const peer = this.resolve(query)
|
|
165
|
+
if (!peer) throw new HubError('unknown_peer', `no mesh peer named "${query}"`)
|
|
166
|
+
if (!members.includes(peer.id)) members.push(peer.id)
|
|
167
|
+
}
|
|
168
|
+
if (members.length < 2) throw new HubError('bad_request', 'a session needs two or more peers')
|
|
169
|
+
if (members.length > 8) throw new HubError('bad_request', 'a session holds at most 8 peers')
|
|
170
|
+
const id = 's-' + randomBytes(4).toString('hex')
|
|
171
|
+
const session = { id, members, policy: makePolicy(), createdAt: this.now() }
|
|
172
|
+
this.sessions.set(id, session)
|
|
173
|
+
this.#log('link', { session: id, peers: members.map((member) => this.peers.get(member).name) })
|
|
174
|
+
return session
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
unlink(sessionId) {
|
|
178
|
+
if (!this.sessions.delete(sessionId)) throw new HubError('unknown_session', `no session "${sessionId}"`)
|
|
179
|
+
this.#log('unlink', { session: sessionId })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Deliver everything currently matured for a peer with seq > cursor. */
|
|
183
|
+
poll(id, after = 0) {
|
|
184
|
+
const peer = this.peers.get(id)
|
|
185
|
+
if (!peer) throw new HubError('unknown_peer', `no peer "${id}"`)
|
|
186
|
+
peer.lastSeen = this.now()
|
|
187
|
+
const inbox = this.inboxes.get(id) ?? []
|
|
188
|
+
const due = inbox.filter((envelope) => envelope.seq > after && envelope.readyAt <= this.now())
|
|
189
|
+
for (const envelope of due) {
|
|
190
|
+
const index = inbox.indexOf(envelope)
|
|
191
|
+
if (index >= 0) inbox.splice(index, 1)
|
|
192
|
+
}
|
|
193
|
+
const cursor = due.length ? due[due.length - 1].seq : after
|
|
194
|
+
return {
|
|
195
|
+
messages: due.map((envelope) => ({
|
|
196
|
+
seq: envelope.seq,
|
|
197
|
+
from: this.peers.get(envelope.from)?.name ?? envelope.from,
|
|
198
|
+
session: envelope.session,
|
|
199
|
+
ts: envelope.ts,
|
|
200
|
+
body: envelope.body,
|
|
201
|
+
})),
|
|
202
|
+
cursor,
|
|
203
|
+
pending: inbox.length,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- messaging ----------------------------------------------------------
|
|
208
|
+
/**
|
|
209
|
+
* Send a JSON message through a session. `from` may be a peer query;
|
|
210
|
+
* `via: 'agent'` marks an observation-window injection. Returns
|
|
211
|
+
* {seq, delivered, dropped, duplicated, readyAt}.
|
|
212
|
+
*/
|
|
213
|
+
send({ session: sessionId, from, body, via }) {
|
|
214
|
+
const session = this.sessions.get(sessionId)
|
|
215
|
+
if (!session) throw new HubError('unknown_session', `no session "${sessionId}"`)
|
|
216
|
+
const sender = this.resolve(from)
|
|
217
|
+
if (!sender) throw new HubError('unknown_peer', `no mesh peer named "${from}"`)
|
|
218
|
+
if (!session.members.includes(sender.id)) {
|
|
219
|
+
throw new HubError('not_member', `${sender.name} is not in session ${session.id}`)
|
|
220
|
+
}
|
|
221
|
+
const serialized = JSON.stringify(body ?? null)
|
|
222
|
+
if (serialized === undefined) throw new HubError('bad_request', 'body must be JSON-serializable')
|
|
223
|
+
if (serialized.length > 64 * 1024) throw new HubError('too_large', 'message body exceeds 64 KiB')
|
|
224
|
+
const seq = ++this.messageSeq
|
|
225
|
+
const ts = this.now()
|
|
226
|
+
const policy = session.policy
|
|
227
|
+
const jitter = policy.jitterMs > 0 ? Math.round(this.random() * policy.jitterMs) : 0
|
|
228
|
+
const throttleExtra = policy.throttleKbps > 0 ? Math.round((serialized.length * 8) / policy.throttleKbps) : 0
|
|
229
|
+
let delivered = 0
|
|
230
|
+
let dropped = 0
|
|
231
|
+
let duplicated = 0
|
|
232
|
+
let firstReadyAt = Infinity
|
|
233
|
+
for (const memberId of session.members) {
|
|
234
|
+
if (memberId === sender.id) continue
|
|
235
|
+
if (policy.dropPct > 0 && this.random() * 100 < policy.dropPct) {
|
|
236
|
+
dropped++
|
|
237
|
+
this.#log('drop', { session: session.id, seq, to: this.peers.get(memberId)?.name ?? memberId, from: sender.name })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const readyAt = ts + policy.latencyMs + jitter + throttleExtra
|
|
241
|
+
const inbox = this.inboxes.get(memberId) ?? []
|
|
242
|
+
inbox.push({ seq, readyAt, ts, from: sender.id, session: session.id, body })
|
|
243
|
+
this.inboxes.set(memberId, inbox)
|
|
244
|
+
delivered++
|
|
245
|
+
firstReadyAt = Math.min(firstReadyAt, readyAt)
|
|
246
|
+
// Long-poll correctness: a delayed envelope matures without a new send, so
|
|
247
|
+
// arm a one-shot wake for exactly that moment (unref'd; never keeps tests or
|
|
248
|
+
// the host alive).
|
|
249
|
+
const wake = setTimeout(() => this.#wake(memberId), Math.max(0, readyAt - this.now()))
|
|
250
|
+
wake.unref?.()
|
|
251
|
+
if (policy.dupPct > 0 && this.random() * 100 < policy.dupPct) {
|
|
252
|
+
inbox.push({ seq, readyAt, ts, from: sender.id, session: session.id, body })
|
|
253
|
+
duplicated++
|
|
254
|
+
this.#log('dup', { session: session.id, seq, to: this.peers.get(memberId)?.name ?? memberId })
|
|
255
|
+
}
|
|
256
|
+
this.#wake(memberId)
|
|
257
|
+
}
|
|
258
|
+
this.#log('send', {
|
|
259
|
+
session: session.id, seq, from: sender.name, to: delivered, dropped, duplicated,
|
|
260
|
+
bytes: serialized.length, via, latencyMs: policy.latencyMs,
|
|
261
|
+
})
|
|
262
|
+
return {
|
|
263
|
+
seq,
|
|
264
|
+
delivered,
|
|
265
|
+
dropped,
|
|
266
|
+
duplicated,
|
|
267
|
+
readyAt: delivered ? firstReadyAt : null,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
tune(sessionId, input) {
|
|
272
|
+
const session = this.sessions.get(sessionId)
|
|
273
|
+
if (!session) throw new HubError('unknown_session', `no session "${sessionId}"`)
|
|
274
|
+
session.policy = makePolicy({ ...session.policy, ...input })
|
|
275
|
+
this.#log('tune', { session: session.id, policy: session.policy })
|
|
276
|
+
return session.policy
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- observation --------------------------------------------------------
|
|
280
|
+
status() {
|
|
281
|
+
return {
|
|
282
|
+
peers: [...this.peers.values()].map((peer) => ({ ...peer })),
|
|
283
|
+
sessions: [...this.sessions.values()].map((session) => ({
|
|
284
|
+
id: session.id,
|
|
285
|
+
members: session.members.map((member) => this.peers.get(member)?.name ?? member),
|
|
286
|
+
policy: { ...session.policy },
|
|
287
|
+
pending: session.members.reduce((sum, member) => sum + (this.inboxes.get(member)?.length ?? 0), 0),
|
|
288
|
+
})),
|
|
289
|
+
messageSeq: this.messageSeq,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
log({ session, limit = 50 } = {}) {
|
|
294
|
+
const entries = this.entries.filter((entry) => !session || entry.session === session)
|
|
295
|
+
return entries.slice(-clamp(limit, 1, this.maxLog))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
reset() {
|
|
299
|
+
this.peers.clear()
|
|
300
|
+
this.sessions.clear()
|
|
301
|
+
this.inboxes.clear()
|
|
302
|
+
this.entries.length = 0
|
|
303
|
+
this.messageSeq = 0
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Long-poll support: resolve waiters when mail for `peerId` may have matured. */
|
|
307
|
+
#wake(peerId) {
|
|
308
|
+
for (const waiter of [...this.waiters]) {
|
|
309
|
+
if (waiter.peerId === peerId) {
|
|
310
|
+
const index = this.waiters.indexOf(waiter)
|
|
311
|
+
if (index >= 0) this.waiters.splice(index, 1)
|
|
312
|
+
waiter.resolve()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
waitFor(peerId, timeoutMs) {
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
const timer = setTimeout(() => {
|
|
320
|
+
const index = this.waiters.indexOf(waiter)
|
|
321
|
+
if (index >= 0) this.waiters.splice(index, 1)
|
|
322
|
+
resolve(false)
|
|
323
|
+
}, clamp(timeoutMs, 0, 30_000))
|
|
324
|
+
const waiter = {
|
|
325
|
+
peerId,
|
|
326
|
+
resolve: () => { clearTimeout(timer); resolve(true) },
|
|
327
|
+
}
|
|
328
|
+
this.waiters.push(waiter)
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#log(kind, fields) {
|
|
333
|
+
this.entries.push({ seq: ++this.seq, ts: this.now(), kind, ...fields })
|
|
334
|
+
if (this.entries.length > this.maxLog) this.entries.splice(0, this.entries.length - this.maxLog)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export class HubError extends Error {
|
|
339
|
+
constructor(code, message) {
|
|
340
|
+
super(message)
|
|
341
|
+
this.code = code
|
|
342
|
+
}
|
|
343
|
+
}
|
package/lib/vision.js
CHANGED
|
@@ -117,14 +117,17 @@ export const IMAGE_REF_SCHEMA = {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* Append the image block to a render's content blocks when the value carries
|
|
121
|
-
* `image` ref
|
|
122
|
-
* array for
|
|
120
|
+
* Append the image block(s) to a render's content blocks when the value carries
|
|
121
|
+
* an `image` ref (or an `images` array of them, as device_pair_capture does) —
|
|
122
|
+
* so an image-capable model SEES the screen(s). Returns the same array for
|
|
123
|
+
* chaining.
|
|
123
124
|
*/
|
|
124
125
|
export function appendImageBlock(blocks, value) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
const refs = Array.isArray(value?.images) ? value.images : [value?.image]
|
|
127
|
+
for (const image of refs) {
|
|
128
|
+
if (image !== undefined && typeof image.attachmentId === 'string') {
|
|
129
|
+
blocks.push({ type: 'image', attachment: image })
|
|
130
|
+
}
|
|
128
131
|
}
|
|
129
132
|
return blocks
|
|
130
133
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
|
-
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session —
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 37 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_batch, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_display, device_avd_create, device_pair_capture, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) plus a host-mediated co-op mesh hub (random callsigns, JSON pub/sub, tunable latency/jitter/drop/dup/throttle) so two virtual devices — and the AI watching them — can talk in real time. One classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
+
"version": "0.8.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"lib/**/*.js",
|
|
37
|
+
"sdk/**/*.mjs",
|
|
37
38
|
"scripts/**/*.py",
|
|
38
39
|
"cordis.patch.yml",
|
|
39
40
|
"README.md"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode mesh — tiny client SDK for co-op game testing (v0.8.0).
|
|
3
|
+
*
|
|
4
|
+
* Plain fetch, no dependencies: runs in Node 18+, Deno, Bun, and any WebView /
|
|
5
|
+
* Expo / React Native JS environment. For non-JS engines (Unity, Godot, plain
|
|
6
|
+
* Kotlin) the protocol is six JSON-over-HTTP endpoints — see README "Co-op
|
|
7
|
+
* mesh". From inside an Android emulator the hub is at
|
|
8
|
+
* http://10.0.2.2:<dsh-port>/api/dsh-mobilecode (default port 3080).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { MeshClient } from 'dsh-mobilecode/sdk/mesh-client.mjs'
|
|
12
|
+
* const mesh = new MeshClient('http://10.0.2.2:3080/api/dsh-mobilecode')
|
|
13
|
+
* const { peer, token } = await mesh.join({ serial: 'emulator-5554' })
|
|
14
|
+
* const { session } = await mesh.link(['brisk-owl']) // find + link peer by callsign
|
|
15
|
+
* await mesh.send(session.id, { type: 'attack', dmg: 12 })
|
|
16
|
+
* for await (const m of mesh.inbox({ waitMs: 5000 })) console.log(m.from, m.body)
|
|
17
|
+
*/
|
|
18
|
+
export class MeshClient {
|
|
19
|
+
constructor(baseUrl = 'http://10.0.2.2:3080/api/dsh-mobilecode') {
|
|
20
|
+
this.base = String(baseUrl).replace(/\/+$/, '')
|
|
21
|
+
this.id = undefined
|
|
22
|
+
this.token = undefined
|
|
23
|
+
this.cursor = 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async #rpc(path, { method = 'GET', body, query } = {}) {
|
|
27
|
+
const url = new URL(this.base + path)
|
|
28
|
+
for (const [key, value] of Object.entries(query ?? {})) if (value !== undefined) url.searchParams.set(key, String(value))
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify({ id: this.id, token: this.token, ...body }),
|
|
33
|
+
})
|
|
34
|
+
const json = await res.json().catch(() => ({ error: 'non-JSON response' }))
|
|
35
|
+
if (!res.ok) throw Object.assign(new Error(json.error ?? `${method} ${path} → ${res.status}`), { status: res.status, code: json.code })
|
|
36
|
+
return json
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Join (or re-join — idempotent per serial). Stores id + token for later calls. */
|
|
40
|
+
async join(input = {}) {
|
|
41
|
+
const json = await this.#rpc('/mesh/join', { method: 'POST', body: input })
|
|
42
|
+
this.id = json.peer.id
|
|
43
|
+
this.token = json.token
|
|
44
|
+
return json // { ok, peer: { id, name, serial, role, ... }, token }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Who else is here, and which sessions exist. */
|
|
48
|
+
peers() { return this.#rpc('/mesh/peers') }
|
|
49
|
+
|
|
50
|
+
/** Link this client with the named peers into a new session. */
|
|
51
|
+
link(withPeers) { return this.#rpc('/mesh/link', { method: 'POST', body: { with: withPeers } }) }
|
|
52
|
+
|
|
53
|
+
/** Send a JSON body to every other member of a session. */
|
|
54
|
+
send(session, body) { return this.#rpc('/mesh/send', { method: 'POST', body: { session, body } }) }
|
|
55
|
+
|
|
56
|
+
/** One drain of matured mail; pass waitMs (<=30000) to long-poll. Advances the cursor. */
|
|
57
|
+
async poll({ waitMs } = {}) {
|
|
58
|
+
const json = await this.#rpc('/mesh/poll', { query: { after: this.cursor, ...(waitMs ? { wait: waitMs } : {}) } })
|
|
59
|
+
if (json.messages?.length) this.cursor = json.cursor
|
|
60
|
+
return json // { messages: [{ seq, from, session, ts, body }], cursor, pending }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Async iterator over incoming messages (long-polls until left). */
|
|
64
|
+
async *inbox({ waitMs = 5000 } = {}) {
|
|
65
|
+
while (this.id) {
|
|
66
|
+
const { messages } = await this.poll({ waitMs })
|
|
67
|
+
for (const message of messages ?? []) yield message
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Leave the mesh; the hub dissolves any session this drops below two members. */
|
|
72
|
+
async leave() {
|
|
73
|
+
const json = await this.#rpc('/mesh/leave', { method: 'POST', body: {} })
|
|
74
|
+
this.id = undefined
|
|
75
|
+
this.token = undefined
|
|
76
|
+
return json
|
|
77
|
+
}
|
|
78
|
+
}
|