viveworker 0.7.0 → 0.8.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/README.md +33 -4
- package/package.json +7 -3
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/viveworker-bridge.mjs +1324 -103
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +634 -9
- package/web/app.js +1731 -187
- package/web/i18n.js +187 -9
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control.mjs — Helpers for hot-toggling the remote-pairing relay from the
|
|
3
|
+
* bridge's settings UI without restarting the bridge process.
|
|
4
|
+
*
|
|
5
|
+
* The orchestrator already exposes a clean `close()` and the
|
|
6
|
+
* `startRemotePairingRelay()` factory is idempotent (each call builds a fresh
|
|
7
|
+
* handle), so "hot restart" is just close-then-start with the current config.
|
|
8
|
+
*
|
|
9
|
+
* Persisting the new config to `~/.viveworker/remote-pairing.env` is a
|
|
10
|
+
* separate concern — the bridge re-reads that file on start, so the toggle
|
|
11
|
+
* has to write through to disk to survive the next launch. Keys are kept in
|
|
12
|
+
* sync with the env-bootstrapping in `viveworker-bridge.mjs`:
|
|
13
|
+
*
|
|
14
|
+
* REMOTE_PAIRING_ENABLED = "true" | "false"
|
|
15
|
+
* REMOTE_PAIRING_RELAY_URL = "wss://..." (optional; "" deletes)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promises as fs } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
import { upsertEnvText } from "../pairing.mjs";
|
|
22
|
+
import { REMOTE_PAIRING_ENV_FILE } from "./keys.mjs";
|
|
23
|
+
import { startRemotePairingRelay } from "./orchestrator.mjs";
|
|
24
|
+
|
|
25
|
+
const ENV_KEY_ENABLED = "REMOTE_PAIRING_ENABLED";
|
|
26
|
+
const ENV_KEY_RELAY_URL = "REMOTE_PAIRING_RELAY_URL";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Close the current orchestrator handle (if any) and start a new one with
|
|
30
|
+
* the values currently in `config`. Returns the new handle and stores it on
|
|
31
|
+
* `runtime.remotePairingHandle`.
|
|
32
|
+
*
|
|
33
|
+
* Caller is expected to have updated `config.remotePairingEnabled` /
|
|
34
|
+
* `config.remotePairingRelayUrl` before invoking this — the helper just
|
|
35
|
+
* snapshots whatever is there now.
|
|
36
|
+
*
|
|
37
|
+
* Errors during teardown are swallowed (best-effort) but errors during
|
|
38
|
+
* startup propagate so the API endpoint can return a useful 500.
|
|
39
|
+
*
|
|
40
|
+
* @param {{
|
|
41
|
+
* runtime: { remotePairingHandle: any },
|
|
42
|
+
* config: { remotePairingEnabled: boolean, remotePairingRelayUrl: string },
|
|
43
|
+
* requestListener: import("./http-dispatch.mjs").RequestListener,
|
|
44
|
+
* logger?: { debug?: Function, warn?: Function, info?: Function, error?: Function },
|
|
45
|
+
* auditEventSink?: (event: object) => void | Promise<void>,
|
|
46
|
+
* }} args
|
|
47
|
+
* @returns {Promise<import("./orchestrator.mjs").RemotePairingHandle>}
|
|
48
|
+
*/
|
|
49
|
+
export async function restartRemotePairingRelay({
|
|
50
|
+
runtime,
|
|
51
|
+
config,
|
|
52
|
+
requestListener,
|
|
53
|
+
logger,
|
|
54
|
+
auditEventSink,
|
|
55
|
+
}) {
|
|
56
|
+
if (!runtime || typeof runtime !== "object") {
|
|
57
|
+
throw new TypeError("restartRemotePairingRelay: runtime required");
|
|
58
|
+
}
|
|
59
|
+
if (typeof requestListener !== "function") {
|
|
60
|
+
throw new TypeError("restartRemotePairingRelay: requestListener required");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Tear down the old handle. .close() is idempotent and safe to call on a
|
|
64
|
+
// dormant handle, but wrap in try anyway since a buggy WebSocketImpl could
|
|
65
|
+
// throw and we don't want that to block the rebuild.
|
|
66
|
+
if (runtime.remotePairingHandle) {
|
|
67
|
+
try {
|
|
68
|
+
runtime.remotePairingHandle.close();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger?.warn?.(`[remote-pairing] close during restart failed: ${err?.message}`);
|
|
71
|
+
}
|
|
72
|
+
runtime.remotePairingHandle = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handle = await startRemotePairingRelay({
|
|
76
|
+
enabled: Boolean(config.remotePairingEnabled),
|
|
77
|
+
relayUrl: config.remotePairingRelayUrl || undefined,
|
|
78
|
+
requestListener,
|
|
79
|
+
logger,
|
|
80
|
+
auditEventSink,
|
|
81
|
+
});
|
|
82
|
+
runtime.remotePairingHandle = handle;
|
|
83
|
+
return handle;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write the runtime config back to `~/.viveworker/remote-pairing.env` so the
|
|
88
|
+
* next bridge startup sees the new values. Other keys in the file (e.g.
|
|
89
|
+
* IDENTITY_KEY_PRIV) are preserved.
|
|
90
|
+
*
|
|
91
|
+
* `relayUrl: null` removes the env key (so the orchestrator falls back to
|
|
92
|
+
* the DEFAULT_RELAY_URL); `relayUrl: ""` is treated the same. A defined
|
|
93
|
+
* non-empty string overwrites.
|
|
94
|
+
*
|
|
95
|
+
* @param {{
|
|
96
|
+
* enabled?: boolean,
|
|
97
|
+
* relayUrl?: string | null,
|
|
98
|
+
* envFile?: string,
|
|
99
|
+
* }} args
|
|
100
|
+
*/
|
|
101
|
+
export async function persistRemotePairingEnv({
|
|
102
|
+
enabled,
|
|
103
|
+
relayUrl,
|
|
104
|
+
envFile = REMOTE_PAIRING_ENV_FILE,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const dir = path.dirname(envFile);
|
|
107
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
108
|
+
|
|
109
|
+
let current = "";
|
|
110
|
+
try {
|
|
111
|
+
current = await fs.readFile(envFile, "utf8");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err.code !== "ENOENT") throw err;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @type {Record<string, string>} */
|
|
117
|
+
const updates = {};
|
|
118
|
+
if (enabled !== undefined) {
|
|
119
|
+
updates[ENV_KEY_ENABLED] = enabled ? "true" : "false";
|
|
120
|
+
}
|
|
121
|
+
if (relayUrl !== undefined) {
|
|
122
|
+
// upsertEnvText with "" leaves the key as `KEY=` (empty value) which the
|
|
123
|
+
// env loader interprets as unset — fine for our purposes.
|
|
124
|
+
updates[ENV_KEY_RELAY_URL] = relayUrl == null ? "" : String(relayUrl);
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(updates).length === 0) return;
|
|
127
|
+
|
|
128
|
+
const updated = upsertEnvText(current, updates);
|
|
129
|
+
await fs.writeFile(envFile, updated, { mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Convenience: return the handle's getStatus() output, or a dormant shape
|
|
134
|
+
* if no handle is mounted yet (bridge starting up, or relay disabled at
|
|
135
|
+
* boot and never enabled since).
|
|
136
|
+
*
|
|
137
|
+
* @param {{ remotePairingHandle: any }} runtime
|
|
138
|
+
* @returns {import("./orchestrator.mjs").RemotePairingStatus}
|
|
139
|
+
*/
|
|
140
|
+
export function getRemotePairingStatus(runtime) {
|
|
141
|
+
const handle = runtime?.remotePairingHandle;
|
|
142
|
+
if (handle && typeof handle.getStatus === "function") {
|
|
143
|
+
return handle.getStatus();
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
enabled: false,
|
|
147
|
+
relayUrl: "",
|
|
148
|
+
identityFingerprint: null,
|
|
149
|
+
identityPubHex: null,
|
|
150
|
+
sessions: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Exposed for tests so they can match keys against the env file directly.
|
|
155
|
+
export const __ENV_KEY_ENABLED = ENV_KEY_ENABLED;
|
|
156
|
+
export const __ENV_KEY_RELAY_URL = ENV_KEY_RELAY_URL;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelope.mjs — Wire envelope for the viveworker remote-pairing relay.
|
|
3
|
+
*
|
|
4
|
+
* Sits ABOVE the WSS framing and BELOW the Noise transport message.
|
|
5
|
+
* The relay (CF Worker + Durable Object) reads the envelope to route
|
|
6
|
+
* frames; it never decrypts the payload.
|
|
7
|
+
*
|
|
8
|
+
* WSS frame body
|
|
9
|
+
* └── envelope (this module) ← visible to relay
|
|
10
|
+
* ├── type (1 byte) ← routes / control
|
|
11
|
+
* ├── seq (4 bytes BE u32) ← per-direction monotonic counter
|
|
12
|
+
* ├── mid (16 bytes UUID) ← dedup key
|
|
13
|
+
* └── payload (N bytes) ← Noise transport message (opaque)
|
|
14
|
+
*
|
|
15
|
+
* Three classes of frame:
|
|
16
|
+
*
|
|
17
|
+
* DATA frames carry application payload (Noise ciphertext) plus seq + mid.
|
|
18
|
+
* These are the frames that hit the replay buffer and earn ACKs.
|
|
19
|
+
*
|
|
20
|
+
* CONTROL frames (PING, PONG, ACK, RESUME_*) are short fixed-format frames
|
|
21
|
+
* that the relay handles or echoes; they don't go in the replay buffer.
|
|
22
|
+
*
|
|
23
|
+
* The handshake itself rides as plain DATA frames — the relay can't tell
|
|
24
|
+
* the difference between handshake and post-handshake transport, which is
|
|
25
|
+
* exactly what we want (no special-cased path for sensitive moments).
|
|
26
|
+
*
|
|
27
|
+
* Byte ordering: big-endian u32 (network order — matches WSS / IETF
|
|
28
|
+
* convention; one less footgun for anyone reading hex dumps).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Frame types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export const FRAME_DATA = 0x01;
|
|
36
|
+
export const FRAME_ACK = 0x02;
|
|
37
|
+
export const FRAME_PING = 0x03;
|
|
38
|
+
export const FRAME_PONG = 0x04;
|
|
39
|
+
export const FRAME_RESUME_REQ = 0x05;
|
|
40
|
+
export const FRAME_RESUME_OK = 0x06;
|
|
41
|
+
export const FRAME_RESUME_FAIL = 0x07;
|
|
42
|
+
|
|
43
|
+
// Reasons for RESUME_FAIL — picked to surface the right user-facing message.
|
|
44
|
+
export const RESUME_FAIL_BUFFER_EXPIRED = 0x01; // requested seq is older than buffer's earliest
|
|
45
|
+
export const RESUME_FAIL_UNKNOWN_PAIRING = 0x02; // DO has no record of this pairing
|
|
46
|
+
export const RESUME_FAIL_HIBERNATED = 0x03; // DO came back from cold; in-memory state lost
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Sizes
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export const MID_BYTES = 16;
|
|
53
|
+
const HEADER_DATA = 1 + 4 + MID_BYTES; // type + seq + mid = 21
|
|
54
|
+
const HEADER_ACK = 1 + 4;
|
|
55
|
+
const HEADER_PING = 1;
|
|
56
|
+
const HEADER_PONG = 1;
|
|
57
|
+
const HEADER_RESUME_REQ = 1 + 4;
|
|
58
|
+
const HEADER_RESUME_OK = 1 + 4;
|
|
59
|
+
const HEADER_RESUME_FAIL = 1 + 1;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function asU8(input) {
|
|
66
|
+
if (input == null) return new Uint8Array(0);
|
|
67
|
+
if (input instanceof Uint8Array) return input;
|
|
68
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
69
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
70
|
+
throw new TypeError(`unsupported envelope input: ${typeof input}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readU32BE(view, offset) {
|
|
74
|
+
return view.getUint32(offset, false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeU32BE(view, offset, value) {
|
|
78
|
+
if (value < 0 || value > 0xffff_ffff) throw new RangeError(`u32 out of range: ${value}`);
|
|
79
|
+
view.setUint32(offset, value >>> 0, false);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate a 16-byte random message id. Uses crypto.getRandomValues which is
|
|
84
|
+
* available in Node 19+, browsers, and Cloudflare Workers.
|
|
85
|
+
*/
|
|
86
|
+
export function generateMid() {
|
|
87
|
+
const out = new Uint8Array(MID_BYTES);
|
|
88
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
89
|
+
globalThis.crypto.getRandomValues(out);
|
|
90
|
+
} else {
|
|
91
|
+
// Last-resort fallback (shouldn't trigger on supported targets).
|
|
92
|
+
for (let i = 0; i < MID_BYTES; i++) out[i] = Math.floor(Math.random() * 256);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function midToHex(mid) {
|
|
98
|
+
let out = "";
|
|
99
|
+
for (let i = 0; i < mid.length; i++) out += mid[i].toString(16).padStart(2, "0");
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Encoders
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export function encodeData({ seq, mid, payload }) {
|
|
108
|
+
const pl = asU8(payload);
|
|
109
|
+
if (mid.length !== MID_BYTES) throw new RangeError("mid must be 16 bytes");
|
|
110
|
+
const out = new Uint8Array(HEADER_DATA + pl.length);
|
|
111
|
+
const view = new DataView(out.buffer);
|
|
112
|
+
out[0] = FRAME_DATA;
|
|
113
|
+
writeU32BE(view, 1, seq);
|
|
114
|
+
out.set(mid, 5);
|
|
115
|
+
out.set(pl, HEADER_DATA);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function encodeAck(seq) {
|
|
120
|
+
const out = new Uint8Array(HEADER_ACK);
|
|
121
|
+
const view = new DataView(out.buffer);
|
|
122
|
+
out[0] = FRAME_ACK;
|
|
123
|
+
writeU32BE(view, 1, seq);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function encodePing() {
|
|
128
|
+
return new Uint8Array([FRAME_PING]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function encodePong() {
|
|
132
|
+
return new Uint8Array([FRAME_PONG]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function encodeResumeReq(lastSeenSeq) {
|
|
136
|
+
const out = new Uint8Array(HEADER_RESUME_REQ);
|
|
137
|
+
const view = new DataView(out.buffer);
|
|
138
|
+
out[0] = FRAME_RESUME_REQ;
|
|
139
|
+
writeU32BE(view, 1, lastSeenSeq);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function encodeResumeOk(currentSeq) {
|
|
144
|
+
const out = new Uint8Array(HEADER_RESUME_OK);
|
|
145
|
+
const view = new DataView(out.buffer);
|
|
146
|
+
out[0] = FRAME_RESUME_OK;
|
|
147
|
+
writeU32BE(view, 1, currentSeq);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function encodeResumeFail(reason) {
|
|
152
|
+
return new Uint8Array([FRAME_RESUME_FAIL, reason & 0xff]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Decoder
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Decode an envelope frame. Returns one of:
|
|
161
|
+
* { type: FRAME_DATA, seq, mid, payload }
|
|
162
|
+
* { type: FRAME_ACK, seq }
|
|
163
|
+
* { type: FRAME_PING | FRAME_PONG }
|
|
164
|
+
* { type: FRAME_RESUME_REQ, lastSeenSeq }
|
|
165
|
+
* { type: FRAME_RESUME_OK, currentSeq }
|
|
166
|
+
* { type: FRAME_RESUME_FAIL, reason }
|
|
167
|
+
*
|
|
168
|
+
* Throws on malformed input. Callers should treat throws as protocol
|
|
169
|
+
* violations — log + close the WS with code 4001.
|
|
170
|
+
*/
|
|
171
|
+
export function decode(bytes) {
|
|
172
|
+
const u8 = asU8(bytes);
|
|
173
|
+
if (u8.length < 1) throw new Error("envelope: empty frame");
|
|
174
|
+
const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
175
|
+
const type = u8[0];
|
|
176
|
+
|
|
177
|
+
switch (type) {
|
|
178
|
+
case FRAME_DATA: {
|
|
179
|
+
if (u8.length < HEADER_DATA) throw new Error("envelope: DATA frame too short");
|
|
180
|
+
const seq = readU32BE(view, 1);
|
|
181
|
+
const mid = u8.slice(5, 5 + MID_BYTES);
|
|
182
|
+
const payload = u8.slice(HEADER_DATA);
|
|
183
|
+
return { type, seq, mid, payload };
|
|
184
|
+
}
|
|
185
|
+
case FRAME_ACK: {
|
|
186
|
+
if (u8.length !== HEADER_ACK) throw new Error("envelope: ACK frame wrong length");
|
|
187
|
+
return { type, seq: readU32BE(view, 1) };
|
|
188
|
+
}
|
|
189
|
+
case FRAME_PING:
|
|
190
|
+
if (u8.length !== HEADER_PING) throw new Error("envelope: PING frame wrong length");
|
|
191
|
+
return { type };
|
|
192
|
+
case FRAME_PONG:
|
|
193
|
+
if (u8.length !== HEADER_PONG) throw new Error("envelope: PONG frame wrong length");
|
|
194
|
+
return { type };
|
|
195
|
+
case FRAME_RESUME_REQ: {
|
|
196
|
+
if (u8.length !== HEADER_RESUME_REQ) throw new Error("envelope: RESUME_REQ frame wrong length");
|
|
197
|
+
return { type, lastSeenSeq: readU32BE(view, 1) };
|
|
198
|
+
}
|
|
199
|
+
case FRAME_RESUME_OK: {
|
|
200
|
+
if (u8.length !== HEADER_RESUME_OK) throw new Error("envelope: RESUME_OK frame wrong length");
|
|
201
|
+
return { type, currentSeq: readU32BE(view, 1) };
|
|
202
|
+
}
|
|
203
|
+
case FRAME_RESUME_FAIL: {
|
|
204
|
+
if (u8.length !== HEADER_RESUME_FAIL) throw new Error("envelope: RESUME_FAIL frame wrong length");
|
|
205
|
+
return { type, reason: u8[1] };
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
throw new Error(`envelope: unknown frame type 0x${type.toString(16)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Stringify a frame type for logs. */
|
|
213
|
+
export function frameTypeName(type) {
|
|
214
|
+
switch (type) {
|
|
215
|
+
case FRAME_DATA: return "DATA";
|
|
216
|
+
case FRAME_ACK: return "ACK";
|
|
217
|
+
case FRAME_PING: return "PING";
|
|
218
|
+
case FRAME_PONG: return "PONG";
|
|
219
|
+
case FRAME_RESUME_REQ: return "RESUME_REQ";
|
|
220
|
+
case FRAME_RESUME_OK: return "RESUME_OK";
|
|
221
|
+
case FRAME_RESUME_FAIL: return "RESUME_FAIL";
|
|
222
|
+
default: return `UNKNOWN(0x${type.toString(16)})`;
|
|
223
|
+
}
|
|
224
|
+
}
|