teleportxr 1.0.87 → 1.0.89
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/client/avatar_service.js +100 -0
- package/client/client.js +4 -0
- package/client/client_manager.js +4 -0
- package/client/geometry_service.js +1 -1
- package/connections/webrtcconnection.js +0 -2
- package/package.json +3 -1
- package/protocol/avatars.js +162 -0
- package/signaling.js +28 -1
- package/test/test_avatar_service.js +139 -0
- package/test/test_avatars.js +140 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-client server-side state for avatar negotiation. Phase 2 of the
|
|
3
|
+
// implementation in plans/avatars_implementation.md: round-trip a policy,
|
|
4
|
+
// receive an offer, always reply with using_default. No validation, no
|
|
5
|
+
// download, no import. Behaviour is the same regardless of whether the
|
|
6
|
+
// client offered an avatar or not.
|
|
7
|
+
//
|
|
8
|
+
// One AvatarService is owned by each Client; messages are dispatched in
|
|
9
|
+
// from the signaling layer.
|
|
10
|
+
|
|
11
|
+
const avatars = require('../protocol/avatars.js');
|
|
12
|
+
|
|
13
|
+
function envelope(type, content) {
|
|
14
|
+
return JSON.stringify({ 'teleport-signal-type': type, content });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class AvatarService {
|
|
18
|
+
constructor(clientID, sigSend) {
|
|
19
|
+
this.clientID = clientID;
|
|
20
|
+
this.sigSend = sigSend;
|
|
21
|
+
this.currentPolicy = null;
|
|
22
|
+
this.lastOffer = null;
|
|
23
|
+
this.lastResult = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Send (or re-send) the policy to the owning client. The client is
|
|
27
|
+
// expected to reply with an avatar-offer.
|
|
28
|
+
sendPolicy(policy) {
|
|
29
|
+
if (!policy)
|
|
30
|
+
return;
|
|
31
|
+
this.currentPolicy = policy;
|
|
32
|
+
const content = policy && typeof policy.toJSON === 'function'
|
|
33
|
+
? policy.toJSON()
|
|
34
|
+
: avatars.parseAvatarPolicy(policy).toJSON();
|
|
35
|
+
console.log('avatar-policy → client ' + this.clientID + ' policy_id=' + content.policy_id);
|
|
36
|
+
this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_POLICY, content));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle an incoming avatar-offer. Phase 2 always replies using_default.
|
|
40
|
+
handleOffer(offerJson) {
|
|
41
|
+
const offer = avatars.parseAvatarOffer(offerJson);
|
|
42
|
+
this.lastOffer = offer;
|
|
43
|
+
console.log('avatar-offer ← client ' + this.clientID +
|
|
44
|
+
' policy_id=' + offer.policy_id +
|
|
45
|
+
' have_avatar=' + offer.have_avatar);
|
|
46
|
+
|
|
47
|
+
// If we have not sent a policy, or the offer references a different
|
|
48
|
+
// policy_id, reject so the client knows it is talking about something
|
|
49
|
+
// the server does not currently care about.
|
|
50
|
+
if (!this.currentPolicy ||
|
|
51
|
+
BigInt(offer.policy_id || 0n) !== BigInt(this.currentPolicy.policy_id))
|
|
52
|
+
{
|
|
53
|
+
this._reply({
|
|
54
|
+
policy_id: offer.policy_id || 0n,
|
|
55
|
+
status: 'rejected',
|
|
56
|
+
node_uid: 0n,
|
|
57
|
+
using_default: false,
|
|
58
|
+
delivery: 'import',
|
|
59
|
+
reasons: ['policy_unknown'],
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Phase 2: regardless of what the client offered, the server uses its
|
|
65
|
+
// default avatar. node_uid is 0 because no real node has been imported.
|
|
66
|
+
this._reply({
|
|
67
|
+
policy_id: offer.policy_id,
|
|
68
|
+
status: 'using_default',
|
|
69
|
+
node_uid: 0n,
|
|
70
|
+
using_default: true,
|
|
71
|
+
delivery: 'import',
|
|
72
|
+
reasons: [],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle a client-initiated revoke (rare in Phase 2; provided for
|
|
77
|
+
// symmetry with later phases).
|
|
78
|
+
handleRevoke(revokeJson) {
|
|
79
|
+
const policy_id = revokeJson && revokeJson.policy_id != null
|
|
80
|
+
? BigInt(revokeJson.policy_id) : 0n;
|
|
81
|
+
console.log('avatar-revoke ← client ' + this.clientID + ' policy_id=' + policy_id);
|
|
82
|
+
// In Phase 2 a revoke from the client just drops cached state; the
|
|
83
|
+
// server keeps the same policy in force and a new offer is expected
|
|
84
|
+
// next.
|
|
85
|
+
this.lastOffer = null;
|
|
86
|
+
this.lastResult = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_reply(result) {
|
|
90
|
+
const content = avatars.encodeAvatarResult(result);
|
|
91
|
+
this.lastResult = content;
|
|
92
|
+
console.log('avatar-result → client ' + this.clientID +
|
|
93
|
+
' status=' + content.status +
|
|
94
|
+
' delivery=' + content.delivery +
|
|
95
|
+
(content.reasons.length ? ' reasons=' + JSON.stringify(content.reasons) : ''));
|
|
96
|
+
this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_RESULT, content));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { AvatarService };
|
package/client/client.js
CHANGED
|
@@ -4,6 +4,7 @@ const core= require("../core/core.js");
|
|
|
4
4
|
const command= require("../protocol/command.js");
|
|
5
5
|
const message= require("../protocol/message.js");
|
|
6
6
|
const gs= require("./geometry_service.js");
|
|
7
|
+
const avatar_service= require("./avatar_service.js");
|
|
7
8
|
const node_encoder= require("../protocol/encoders/node_encoder.js");
|
|
8
9
|
const resource_encoder= require("../protocol/encoders/resource_encoder.js");
|
|
9
10
|
const WebRtcConnectionManager = require('../connections/webrtcconnectionmanager');
|
|
@@ -54,6 +55,9 @@ class Client {
|
|
|
54
55
|
this.origin_uid=0;
|
|
55
56
|
this.handshakeMessage=new message.HandshakeMessage();
|
|
56
57
|
this.geometryService=new gs.GeometryService(cid);
|
|
58
|
+
// Per-client avatar negotiation state. The host application drives
|
|
59
|
+
// when (and whether) policy is sent via this service.
|
|
60
|
+
this.avatarService=new avatar_service.AvatarService(cid, sigSend);
|
|
57
61
|
this.webRtcConnected=false;
|
|
58
62
|
this.webRtcConnection=null;
|
|
59
63
|
this.currentOriginState=new OriginState();
|
package/client/client_manager.js
CHANGED
|
@@ -111,6 +111,10 @@ class ClientManager
|
|
|
111
111
|
// then we tell the client manager to start this client.
|
|
112
112
|
var c=this.GetOrCreateClient(clientID);
|
|
113
113
|
signalingClient.receiveReliableBinaryMessage=c.receiveReliableBinaryMessage.bind(c);
|
|
114
|
+
// Route avatar-offer / avatar-revoke text frames to the per-client
|
|
115
|
+
// AvatarService. The signaling layer dispatches by message type.
|
|
116
|
+
signalingClient.handleAvatarOffer=c.avatarService.handleOffer.bind(c.avatarService);
|
|
117
|
+
signalingClient.handleAvatarRevoke=c.avatarService.handleRevoke.bind(c.avatarService);
|
|
114
118
|
//c.SetScene(this.scene);
|
|
115
119
|
c.Start();
|
|
116
120
|
return c;
|
|
@@ -126,7 +126,7 @@ class GeometryService {
|
|
|
126
126
|
// MAY not be in this set:
|
|
127
127
|
this.streamedNodes.delete(uid);
|
|
128
128
|
// TODO: now reduce the counts for all the dependent resources.
|
|
129
|
-
console.log("Unstreaming node ",
|
|
129
|
+
console.log("Unstreaming node ", uid," for client ", this.clientID);
|
|
130
130
|
}
|
|
131
131
|
StreamOrUnstream(resourceMap, uid, diff) {
|
|
132
132
|
// exclude "undefined"
|
|
@@ -431,7 +431,6 @@ class WebRtcConnection extends EventEmitter
|
|
|
431
431
|
|
|
432
432
|
this.videoDataChannel = this.createDataChannel("video",20);
|
|
433
433
|
this.tagDataChannel = this.createDataChannel("video_tags",40);
|
|
434
|
-
this.audioToClientDataChannel = this.createDataChannel("audio_server_to_client",60);
|
|
435
434
|
this.geometryDataChannel = this.createDataChannel("geometry_unframed",80);
|
|
436
435
|
this.reliableDataChannel = this.createDataChannel("reliable",100);
|
|
437
436
|
this.unreliableDataChannel = this.createDataChannel("unreliable",120,false);
|
|
@@ -585,7 +584,6 @@ class WebRtcConnection extends EventEmitter
|
|
|
585
584
|
{
|
|
586
585
|
//.videoDataChannel = .("video",20);
|
|
587
586
|
//.tagDataChannel = .("video_tags",40);
|
|
588
|
-
//.audioToClientDataChannel = .("audio_server_to_client",60);
|
|
589
587
|
//.geometryDataChannel =l("geometry_unframed",80);
|
|
590
588
|
//.reliableDataChannel =l("reliable",100);
|
|
591
589
|
//.unreliableDataChannelnel("unreliable",120,false);
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"name": "teleportxr",
|
|
18
18
|
"description": "Teleport Spatial Server on node.js",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.89",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "git+https://github.com/simul/teleport-nodejs.git"
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
"./signaling": "./signaling.js",
|
|
33
33
|
"./client/client": "./client/client.js",
|
|
34
34
|
"./client/client_manager": "./client/client_manager.js",
|
|
35
|
+
"./client/avatar_service": "./client/avatar_service.js",
|
|
35
36
|
"./connections/webrtcconnectionmanager": "./connections/webrtcconnectionmanager.js",
|
|
37
|
+
"./protocol/avatars": "./protocol/avatars.js",
|
|
36
38
|
"./scene/scene": "./scene/scene.js",
|
|
37
39
|
"./scene/node": "./scene/node.js",
|
|
38
40
|
"./scene/resources": "./scene/resources.js"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// JSON codecs for the avatar-negotiation signaling messages.
|
|
3
|
+
// Wire format mirrors Teleport/TeleportCore/Avatars.h and is documented
|
|
4
|
+
// in Teleport/docs/protocol/signaling.rst.
|
|
5
|
+
//
|
|
6
|
+
// Each codec is a tiny pair of plain JS helpers so callers can build the
|
|
7
|
+
// JSON object directly, or round-trip an incoming JSON value into a
|
|
8
|
+
// well-typed object. Unknown keys on incoming objects are preserved so
|
|
9
|
+
// future protocol fields survive a parse+emit cycle.
|
|
10
|
+
|
|
11
|
+
const TELEPORT_SIGNAL_TYPE_AVATAR_POLICY = 'avatar-policy';
|
|
12
|
+
const TELEPORT_SIGNAL_TYPE_AVATAR_OFFER = 'avatar-offer';
|
|
13
|
+
const TELEPORT_SIGNAL_TYPE_AVATAR_RESULT = 'avatar-result';
|
|
14
|
+
const TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE = 'avatar-revoke';
|
|
15
|
+
const TELEPORT_SIGNAL_TYPE_PEER_AVATAR = 'peer-avatar';
|
|
16
|
+
const TELEPORT_SIGNAL_TYPE_PEER_AVATAR_FAILED = 'peer-avatar-failed';
|
|
17
|
+
|
|
18
|
+
// SignalingCapabilities ------------------------------------------------
|
|
19
|
+
// Free-form capability bag advertised on the `connect` envelope. Unknown
|
|
20
|
+
// keys are ignored on read and dropped on write — first-class flags only.
|
|
21
|
+
|
|
22
|
+
function decodeCapabilities(raw) {
|
|
23
|
+
const out = { avatar_relay: false };
|
|
24
|
+
if (raw && typeof raw === 'object' && typeof raw.avatar_relay === 'boolean') {
|
|
25
|
+
out.avatar_relay = raw.avatar_relay;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodeCapabilities(caps) {
|
|
31
|
+
return { avatar_relay: !!(caps && caps.avatar_relay) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// AvatarPolicy ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class AvatarPolicy {
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
this.policy_id = BigInt(opts.policy_id || 0n);
|
|
39
|
+
this.requirement = opts.requirement || 'optional'; // required | optional | forbidden
|
|
40
|
+
this.default_available = !!opts.default_available;
|
|
41
|
+
this.requirements = opts.requirements || {};
|
|
42
|
+
this.proof = Object.assign({ required: false, accepted_schemes: [] }, opts.proof || {});
|
|
43
|
+
if (opts.fetch_timeout_ms != null) this.fetch_timeout_ms = opts.fetch_timeout_ms;
|
|
44
|
+
}
|
|
45
|
+
toJSON() {
|
|
46
|
+
const j = {
|
|
47
|
+
policy_id: Number(this.policy_id),
|
|
48
|
+
requirement: this.requirement,
|
|
49
|
+
default_available: this.default_available,
|
|
50
|
+
requirements: this.requirements,
|
|
51
|
+
proof: {
|
|
52
|
+
required: !!this.proof.required,
|
|
53
|
+
accepted_schemes: Array.isArray(this.proof.accepted_schemes) ? this.proof.accepted_schemes : []
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
if (this.fetch_timeout_ms != null) j.fetch_timeout_ms = this.fetch_timeout_ms;
|
|
57
|
+
return j;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseAvatarPolicy(j) {
|
|
62
|
+
const p = new AvatarPolicy();
|
|
63
|
+
if (!j || typeof j !== 'object') return p;
|
|
64
|
+
if (j.policy_id != null) p.policy_id = BigInt(j.policy_id);
|
|
65
|
+
if (j.requirement != null) p.requirement = String(j.requirement);
|
|
66
|
+
if (j.default_available != null) p.default_available = !!j.default_available;
|
|
67
|
+
if (j.requirements != null) p.requirements = j.requirements;
|
|
68
|
+
if (j.proof != null) p.proof = {
|
|
69
|
+
required: !!j.proof.required,
|
|
70
|
+
accepted_schemes: Array.isArray(j.proof.accepted_schemes) ? j.proof.accepted_schemes.slice() : []
|
|
71
|
+
};
|
|
72
|
+
if (j.fetch_timeout_ms != null) p.fetch_timeout_ms = j.fetch_timeout_ms;
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// AvatarOffer ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function parseAvatarOffer(j) {
|
|
79
|
+
const o = { policy_id: 0n, have_avatar: false };
|
|
80
|
+
if (!j || typeof j !== 'object') return o;
|
|
81
|
+
if (j.policy_id != null) o.policy_id = BigInt(j.policy_id);
|
|
82
|
+
if (j.have_avatar != null) o.have_avatar = !!j.have_avatar;
|
|
83
|
+
if (j.url != null) o.url = String(j.url);
|
|
84
|
+
if (j.content_hash != null) o.content_hash = String(j.content_hash);
|
|
85
|
+
if (j.declared && typeof j.declared === 'object') {
|
|
86
|
+
o.declared = {
|
|
87
|
+
format: j.declared.format ? String(j.declared.format) : '',
|
|
88
|
+
};
|
|
89
|
+
if (j.declared.file_bytes != null) o.declared.file_bytes = Number(j.declared.file_bytes);
|
|
90
|
+
if (j.declared.triangles != null) o.declared.triangles = Number(j.declared.triangles);
|
|
91
|
+
}
|
|
92
|
+
if (j.proof && typeof j.proof === 'object') {
|
|
93
|
+
o.proof = {
|
|
94
|
+
scheme: j.proof.scheme ? String(j.proof.scheme) : '',
|
|
95
|
+
value: j.proof.value ? String(j.proof.value) : ''
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (j.allow_relay != null) o.allow_relay = !!j.allow_relay;
|
|
99
|
+
return o;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function encodeAvatarOffer(o) {
|
|
103
|
+
const j = { policy_id: Number(o.policy_id || 0n), have_avatar: !!o.have_avatar };
|
|
104
|
+
if (o.url != null) j.url = String(o.url);
|
|
105
|
+
if (o.content_hash != null) j.content_hash = String(o.content_hash);
|
|
106
|
+
if (o.declared) j.declared = Object.assign({}, o.declared);
|
|
107
|
+
if (o.proof) j.proof = Object.assign({}, o.proof);
|
|
108
|
+
if (o.allow_relay != null) j.allow_relay = !!o.allow_relay;
|
|
109
|
+
return j;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// AvatarResult / Revoke / Peer messages --------------------------------
|
|
113
|
+
|
|
114
|
+
function encodeAvatarResult(r) {
|
|
115
|
+
return {
|
|
116
|
+
policy_id: Number(r.policy_id || 0n),
|
|
117
|
+
status: r.status || 'rejected', // accepted | rejected | pending
|
|
118
|
+
node_uid: Number(r.node_uid || 0n),
|
|
119
|
+
using_default: !!r.using_default,
|
|
120
|
+
delivery: r.delivery || 'import', // import | relay
|
|
121
|
+
reasons: Array.isArray(r.reasons) ? r.reasons.slice() : []
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function encodeAvatarRevoke(r) {
|
|
126
|
+
return { policy_id: Number(r.policy_id || 0n), reason: r.reason || '' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function encodePeerAvatar(p) {
|
|
130
|
+
const j = {
|
|
131
|
+
peer_client_id: Number(p.peer_client_id || 0n),
|
|
132
|
+
peer_node_uid: Number(p.peer_node_uid || 0n),
|
|
133
|
+
revoked: !!p.revoked
|
|
134
|
+
};
|
|
135
|
+
if (p.url != null) j.url = String(p.url);
|
|
136
|
+
if (p.content_hash != null) j.content_hash = String(p.content_hash);
|
|
137
|
+
if (p.format != null) j.format = String(p.format);
|
|
138
|
+
if (p.proof) j.proof = Object.assign({}, p.proof);
|
|
139
|
+
return j;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parsePeerAvatarFailed(j) {
|
|
143
|
+
const f = { peer_node_uid: 0n, reason: '' };
|
|
144
|
+
if (!j || typeof j !== 'object') return f;
|
|
145
|
+
if (j.peer_node_uid != null) f.peer_node_uid = BigInt(j.peer_node_uid);
|
|
146
|
+
if (j.reason != null) f.reason = String(j.reason);
|
|
147
|
+
return f;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
TELEPORT_SIGNAL_TYPE_AVATAR_POLICY,
|
|
152
|
+
TELEPORT_SIGNAL_TYPE_AVATAR_OFFER,
|
|
153
|
+
TELEPORT_SIGNAL_TYPE_AVATAR_RESULT,
|
|
154
|
+
TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE,
|
|
155
|
+
TELEPORT_SIGNAL_TYPE_PEER_AVATAR,
|
|
156
|
+
TELEPORT_SIGNAL_TYPE_PEER_AVATAR_FAILED,
|
|
157
|
+
decodeCapabilities, encodeCapabilities,
|
|
158
|
+
AvatarPolicy, parseAvatarPolicy,
|
|
159
|
+
parseAvatarOffer, encodeAvatarOffer,
|
|
160
|
+
encodeAvatarResult, encodeAvatarRevoke,
|
|
161
|
+
encodePeerAvatar, parsePeerAvatarFailed,
|
|
162
|
+
};
|
package/signaling.js
CHANGED
|
@@ -4,6 +4,7 @@ const getcurrentline = require("get-current-line").default;
|
|
|
4
4
|
// Importing the required modules
|
|
5
5
|
const WebSocketServer = require("ws");
|
|
6
6
|
const core = require("./core/core.js");
|
|
7
|
+
const avatars = require("./protocol/avatars.js");
|
|
7
8
|
|
|
8
9
|
class SignalingState {
|
|
9
10
|
static START = new SignalingState("Start");
|
|
@@ -22,7 +23,7 @@ class SignalingState {
|
|
|
22
23
|
}
|
|
23
24
|
var serverID=BigInt(0n);
|
|
24
25
|
|
|
25
|
-
class SignalingClient {
|
|
26
|
+
class SignalingClient {
|
|
26
27
|
constructor(ip, ws, id) {
|
|
27
28
|
this.ip = ip;
|
|
28
29
|
this.ws = ws;
|
|
@@ -31,6 +32,15 @@ class SignalingClient {
|
|
|
31
32
|
this.signalingState = SignalingState.START;
|
|
32
33
|
this.clientID = id;
|
|
33
34
|
this.receiveReliableBinaryMessage=null;
|
|
35
|
+
// Handlers wired by the per-client Client when it is created
|
|
36
|
+
// (see ClientManager.newClient). Avatar negotiation messages are
|
|
37
|
+
// JSON text frames so they cannot share the WebRTC binary path.
|
|
38
|
+
this.handleAvatarOffer=null;
|
|
39
|
+
this.handleAvatarRevoke=null;
|
|
40
|
+
// Session-level capabilities advertised by the client in its
|
|
41
|
+
// `connect` message. Defaults to all-false so that an older
|
|
42
|
+
// client which omits the field is treated conservatively.
|
|
43
|
+
this.capabilities = { avatar_relay: false };
|
|
34
44
|
}
|
|
35
45
|
ChangeSignalingState(newState) {
|
|
36
46
|
console.log(
|
|
@@ -80,6 +90,11 @@ function processDisconnection(clientID,signalingClient){
|
|
|
80
90
|
signalingClients.delete(clientID);
|
|
81
91
|
}
|
|
82
92
|
function processInitialRequest(clientID, signalingClient, content) {
|
|
93
|
+
// Free-form capability bag: a missing / malformed object leaves
|
|
94
|
+
// capabilities at their default (all-false) state.
|
|
95
|
+
if (content && typeof content === 'object' && content.capabilities) {
|
|
96
|
+
signalingClient.capabilities = avatars.decodeCapabilities(content.capabilities);
|
|
97
|
+
}
|
|
83
98
|
var j_clientID = 0;
|
|
84
99
|
if (content.hasOwnProperty("clientID")) {
|
|
85
100
|
var j_clientID = content["clientID"];
|
|
@@ -162,6 +177,18 @@ function receiveWebSocketsMessage(clientID, signalingClient, txt) {
|
|
|
162
177
|
{
|
|
163
178
|
processDisconnection(clientID, signalingClient);
|
|
164
179
|
}
|
|
180
|
+
else if (teleport_signal_type == avatars.TELEPORT_SIGNAL_TYPE_AVATAR_OFFER)
|
|
181
|
+
{
|
|
182
|
+
if (signalingClient.handleAvatarOffer)
|
|
183
|
+
signalingClient.handleAvatarOffer(message["content"]);
|
|
184
|
+
else
|
|
185
|
+
console.log("avatar-offer received for client " + clientID + " but no handler is wired.");
|
|
186
|
+
}
|
|
187
|
+
else if (teleport_signal_type == avatars.TELEPORT_SIGNAL_TYPE_AVATAR_REVOKE)
|
|
188
|
+
{
|
|
189
|
+
if (signalingClient.handleAvatarRevoke)
|
|
190
|
+
signalingClient.handleAvatarRevoke(message["content"]);
|
|
191
|
+
}
|
|
165
192
|
else
|
|
166
193
|
{
|
|
167
194
|
var webRtcConnection = webRtcConnectionManager.getConnection(clientID);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Tests for the per-client AvatarService introduced in Phase 2 of the
|
|
3
|
+
// avatars implementation plan. The service:
|
|
4
|
+
// * serialises a policy and emits it as an avatar-policy text frame;
|
|
5
|
+
// * receives an avatar-offer and replies with avatar-result.
|
|
6
|
+
// Phase 2 always replies status=using_default for a matching policy and
|
|
7
|
+
// status=rejected,reasons=[policy_unknown] for anything else.
|
|
8
|
+
|
|
9
|
+
const test = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
|
|
12
|
+
const avatars = require('../protocol/avatars.js');
|
|
13
|
+
const avatar_service = require('../client/avatar_service.js');
|
|
14
|
+
|
|
15
|
+
// Tiny helper that captures every signaling string the service emits.
|
|
16
|
+
function makeSink() {
|
|
17
|
+
const sent = [];
|
|
18
|
+
return { send: (s) => sent.push(JSON.parse(s)), sent };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('sendPolicy emits an avatar-policy envelope with the policy content', () => {
|
|
22
|
+
const sink = makeSink();
|
|
23
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
24
|
+
const policy = new avatars.AvatarPolicy({
|
|
25
|
+
policy_id: 12345n,
|
|
26
|
+
requirement: 'optional',
|
|
27
|
+
default_available: true,
|
|
28
|
+
requirements: { formats: ['glb'], max_file_bytes: 8388608 },
|
|
29
|
+
});
|
|
30
|
+
svc.sendPolicy(policy);
|
|
31
|
+
assert.strictEqual(sink.sent.length, 1);
|
|
32
|
+
const msg = sink.sent[0];
|
|
33
|
+
assert.strictEqual(msg['teleport-signal-type'], 'avatar-policy');
|
|
34
|
+
assert.strictEqual(msg.content.policy_id, 12345);
|
|
35
|
+
assert.strictEqual(msg.content.requirement, 'optional');
|
|
36
|
+
assert.strictEqual(msg.content.default_available, true);
|
|
37
|
+
assert.deepStrictEqual(msg.content.requirements.formats, ['glb']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('handleOffer replies with using_default when policy_id matches', () => {
|
|
41
|
+
const sink = makeSink();
|
|
42
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
43
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 7n, default_available: true }));
|
|
44
|
+
sink.sent.length = 0;
|
|
45
|
+
svc.handleOffer({ policy_id: 7, have_avatar: false });
|
|
46
|
+
assert.strictEqual(sink.sent.length, 1);
|
|
47
|
+
const msg = sink.sent[0];
|
|
48
|
+
assert.strictEqual(msg['teleport-signal-type'], 'avatar-result');
|
|
49
|
+
assert.strictEqual(msg.content.policy_id, 7);
|
|
50
|
+
assert.strictEqual(msg.content.status, 'using_default');
|
|
51
|
+
assert.strictEqual(msg.content.using_default, true);
|
|
52
|
+
assert.strictEqual(msg.content.delivery, 'import');
|
|
53
|
+
assert.strictEqual(msg.content.node_uid, 0);
|
|
54
|
+
assert.deepStrictEqual(msg.content.reasons, []);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('handleOffer still replies using_default when client supplies an avatar', () => {
|
|
58
|
+
// Phase 2 ignores the offer contents entirely; this asserts that we
|
|
59
|
+
// don't accidentally accept a URL.
|
|
60
|
+
const sink = makeSink();
|
|
61
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
62
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 9n, default_available: true }));
|
|
63
|
+
sink.sent.length = 0;
|
|
64
|
+
svc.handleOffer({
|
|
65
|
+
policy_id: 9,
|
|
66
|
+
have_avatar: true,
|
|
67
|
+
url: 'https://example.com/avatar.glb',
|
|
68
|
+
content_hash: 'sha256:abcd',
|
|
69
|
+
declared: { format: 'glb', file_bytes: 4096 },
|
|
70
|
+
});
|
|
71
|
+
assert.strictEqual(sink.sent[0].content.status, 'using_default');
|
|
72
|
+
assert.strictEqual(sink.sent[0].content.using_default, true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('handleOffer rejects with policy_unknown when policy_id does not match', () => {
|
|
76
|
+
const sink = makeSink();
|
|
77
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
78
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 100n, default_available: true }));
|
|
79
|
+
sink.sent.length = 0;
|
|
80
|
+
svc.handleOffer({ policy_id: 999, have_avatar: false });
|
|
81
|
+
const msg = sink.sent[0];
|
|
82
|
+
assert.strictEqual(msg.content.status, 'rejected');
|
|
83
|
+
assert.deepStrictEqual(msg.content.reasons, ['policy_unknown']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('handleOffer rejects when no policy has been sent yet', () => {
|
|
87
|
+
const sink = makeSink();
|
|
88
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
89
|
+
svc.handleOffer({ policy_id: 1, have_avatar: false });
|
|
90
|
+
assert.strictEqual(sink.sent[0].content.status, 'rejected');
|
|
91
|
+
assert.deepStrictEqual(sink.sent[0].content.reasons, ['policy_unknown']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('handleRevoke clears cached offer state without emitting anything', () => {
|
|
95
|
+
const sink = makeSink();
|
|
96
|
+
const svc = new avatar_service.AvatarService(42n, sink.send);
|
|
97
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 5n }));
|
|
98
|
+
svc.handleOffer({ policy_id: 5, have_avatar: false });
|
|
99
|
+
sink.sent.length = 0;
|
|
100
|
+
svc.handleRevoke({ policy_id: 5 });
|
|
101
|
+
assert.strictEqual(sink.sent.length, 0);
|
|
102
|
+
assert.strictEqual(svc.lastOffer, null);
|
|
103
|
+
assert.strictEqual(svc.lastResult, null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('signaling dispatch: avatar-offer routed to handleAvatarOffer', () => {
|
|
107
|
+
// Round-trip the dispatch path: build a SignalingClient stub, attach a
|
|
108
|
+
// handler, and feed it a JSON frame. Reaching into the module the same
|
|
109
|
+
// way test_avatars.js does because SignalingClient is not exported.
|
|
110
|
+
const fs = require('node:fs');
|
|
111
|
+
const path = require('node:path');
|
|
112
|
+
const Module = require('node:module');
|
|
113
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'signaling.js'), 'utf8');
|
|
114
|
+
const m = new Module(require.resolve('../signaling.js'));
|
|
115
|
+
m.filename = require.resolve('../signaling.js');
|
|
116
|
+
m.paths = Module._nodeModulePaths(m.filename);
|
|
117
|
+
m._compile(src + '\nmodule.exports._SignalingClient = SignalingClient;'
|
|
118
|
+
+ '\nmodule.exports._receiveWebSocketsMessage = receiveWebSocketsMessage;\n', m.filename);
|
|
119
|
+
const SignalingClient = m.exports._SignalingClient;
|
|
120
|
+
const receive = m.exports._receiveWebSocketsMessage;
|
|
121
|
+
|
|
122
|
+
const sc = new SignalingClient('1.2.3.4', { send: () => {} }, 1n);
|
|
123
|
+
let receivedOffer = null;
|
|
124
|
+
sc.handleAvatarOffer = (o) => { receivedOffer = o; };
|
|
125
|
+
let receivedRevoke = null;
|
|
126
|
+
sc.handleAvatarRevoke = (r) => { receivedRevoke = r; };
|
|
127
|
+
|
|
128
|
+
receive(1n, sc, JSON.stringify({
|
|
129
|
+
'teleport-signal-type': 'avatar-offer',
|
|
130
|
+
content: { policy_id: 1, have_avatar: false },
|
|
131
|
+
}));
|
|
132
|
+
assert.deepStrictEqual(receivedOffer, { policy_id: 1, have_avatar: false });
|
|
133
|
+
|
|
134
|
+
receive(1n, sc, JSON.stringify({
|
|
135
|
+
'teleport-signal-type': 'avatar-revoke',
|
|
136
|
+
content: { policy_id: 1, reason: 'replaced' },
|
|
137
|
+
}));
|
|
138
|
+
assert.deepStrictEqual(receivedRevoke, { policy_id: 1, reason: 'replaced' });
|
|
139
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Unit tests for the avatar-negotiation JSON codecs in protocol/avatars.js
|
|
3
|
+
// and the connect-time `capabilities` parsing in signaling.js.
|
|
4
|
+
// Mirrors the C++ test suite in Teleport/test/test_avatars.cpp so a
|
|
5
|
+
// regression on either side surfaces in matching test cases.
|
|
6
|
+
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const assert = require('node:assert');
|
|
9
|
+
|
|
10
|
+
const avatars = require('../protocol/avatars.js');
|
|
11
|
+
|
|
12
|
+
test('decodeCapabilities returns all-false for missing / empty / non-object input', () => {
|
|
13
|
+
assert.deepStrictEqual(avatars.decodeCapabilities(undefined), { avatar_relay: false });
|
|
14
|
+
assert.deepStrictEqual(avatars.decodeCapabilities(null), { avatar_relay: false });
|
|
15
|
+
assert.deepStrictEqual(avatars.decodeCapabilities({}), { avatar_relay: false });
|
|
16
|
+
assert.deepStrictEqual(avatars.decodeCapabilities('nope'), { avatar_relay: false });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('decodeCapabilities reads avatar_relay and ignores unknown future keys', () => {
|
|
20
|
+
const caps = avatars.decodeCapabilities({ avatar_relay: true, future_flag: 'whatever' });
|
|
21
|
+
assert.strictEqual(caps.avatar_relay, true);
|
|
22
|
+
assert.strictEqual('future_flag' in caps, false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('decodeCapabilities ignores non-boolean avatar_relay', () => {
|
|
26
|
+
const caps = avatars.decodeCapabilities({ avatar_relay: 'yes' });
|
|
27
|
+
assert.strictEqual(caps.avatar_relay, false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('encodeCapabilities drops unknown keys and coerces to boolean', () => {
|
|
31
|
+
assert.deepStrictEqual(avatars.encodeCapabilities({ avatar_relay: 1, junk: 'x' }), { avatar_relay: true });
|
|
32
|
+
assert.deepStrictEqual(avatars.encodeCapabilities({}), { avatar_relay: false });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('AvatarPolicy: toJSON / parseAvatarPolicy round-trip', () => {
|
|
36
|
+
const policy = new avatars.AvatarPolicy({
|
|
37
|
+
policy_id: 12345n,
|
|
38
|
+
requirement: 'required',
|
|
39
|
+
default_available: true,
|
|
40
|
+
requirements: { formats: ['glb', 'vrm'], max_file_bytes: 8388608, max_triangles: 60000, skeleton: 'humanoid' },
|
|
41
|
+
proof: { required: true, accepted_schemes: ['jws-detached', 'well-known-url'] },
|
|
42
|
+
fetch_timeout_ms: 7500,
|
|
43
|
+
});
|
|
44
|
+
const wire = JSON.parse(JSON.stringify(policy));
|
|
45
|
+
const parsed = avatars.parseAvatarPolicy(wire);
|
|
46
|
+
assert.strictEqual(parsed.policy_id, 12345n);
|
|
47
|
+
assert.strictEqual(parsed.requirement, 'required');
|
|
48
|
+
assert.strictEqual(parsed.default_available, true);
|
|
49
|
+
assert.deepStrictEqual(parsed.requirements.formats, ['glb', 'vrm']);
|
|
50
|
+
assert.strictEqual(parsed.proof.required, true);
|
|
51
|
+
assert.deepStrictEqual(parsed.proof.accepted_schemes, ['jws-detached', 'well-known-url']);
|
|
52
|
+
assert.strictEqual(parsed.fetch_timeout_ms, 7500);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('parseAvatarOffer handles the have_avatar=false short-form', () => {
|
|
56
|
+
const o = avatars.parseAvatarOffer({ policy_id: 7, have_avatar: false });
|
|
57
|
+
assert.strictEqual(o.policy_id, 7n);
|
|
58
|
+
assert.strictEqual(o.have_avatar, false);
|
|
59
|
+
assert.strictEqual(o.url, undefined);
|
|
60
|
+
assert.strictEqual(o.declared, undefined);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('parseAvatarOffer + encodeAvatarOffer round-trip a full offer', () => {
|
|
64
|
+
const offer = {
|
|
65
|
+
policy_id: 42n,
|
|
66
|
+
have_avatar: true,
|
|
67
|
+
url: 'https://avatars.example.com/u/42.glb',
|
|
68
|
+
content_hash: 'sha256:abcd',
|
|
69
|
+
declared: { format: 'glb', file_bytes: 4096, triangles: 1200 },
|
|
70
|
+
proof: { scheme: 'jws-detached', value: 'eyJ...' },
|
|
71
|
+
allow_relay: false,
|
|
72
|
+
};
|
|
73
|
+
const wire = avatars.encodeAvatarOffer(offer);
|
|
74
|
+
const back = avatars.parseAvatarOffer(wire);
|
|
75
|
+
assert.strictEqual(back.policy_id, 42n);
|
|
76
|
+
assert.strictEqual(back.have_avatar, true);
|
|
77
|
+
assert.strictEqual(back.url, offer.url);
|
|
78
|
+
assert.strictEqual(back.content_hash, offer.content_hash);
|
|
79
|
+
assert.deepStrictEqual(back.declared, offer.declared);
|
|
80
|
+
assert.deepStrictEqual(back.proof, offer.proof);
|
|
81
|
+
assert.strictEqual(back.allow_relay, false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('encodeAvatarResult fills sensible defaults for missing fields', () => {
|
|
85
|
+
const r = avatars.encodeAvatarResult({ policy_id: 3n, status: 'accepted', node_uid: 999n, delivery: 'relay' });
|
|
86
|
+
assert.strictEqual(r.policy_id, 3);
|
|
87
|
+
assert.strictEqual(r.status, 'accepted');
|
|
88
|
+
assert.strictEqual(r.node_uid, 999);
|
|
89
|
+
assert.strictEqual(r.using_default, false);
|
|
90
|
+
assert.strictEqual(r.delivery, 'relay');
|
|
91
|
+
assert.deepStrictEqual(r.reasons, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('encodeAvatarRevoke produces the expected envelope', () => {
|
|
95
|
+
assert.deepStrictEqual(
|
|
96
|
+
avatars.encodeAvatarRevoke({ policy_id: 17n, reason: 'licence_expired' }),
|
|
97
|
+
{ policy_id: 17, reason: 'licence_expired' }
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('encodePeerAvatar carries url / hash / format / proof', () => {
|
|
102
|
+
const wire = avatars.encodePeerAvatar({
|
|
103
|
+
peer_client_id: 100n,
|
|
104
|
+
peer_node_uid: 200n,
|
|
105
|
+
url: 'https://example.com/a.glb',
|
|
106
|
+
content_hash: 'sha256:ff',
|
|
107
|
+
format: 'glb',
|
|
108
|
+
proof: { scheme: 'well-known-url', value: 'https://example.com/.well-known/avatar-binding' },
|
|
109
|
+
});
|
|
110
|
+
assert.strictEqual(wire.peer_client_id, 100);
|
|
111
|
+
assert.strictEqual(wire.peer_node_uid, 200);
|
|
112
|
+
assert.strictEqual(wire.url, 'https://example.com/a.glb');
|
|
113
|
+
assert.strictEqual(wire.content_hash, 'sha256:ff');
|
|
114
|
+
assert.strictEqual(wire.format, 'glb');
|
|
115
|
+
assert.strictEqual(wire.proof.scheme, 'well-known-url');
|
|
116
|
+
assert.strictEqual(wire.revoked, false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('parsePeerAvatarFailed round-trips peer_node_uid and reason', () => {
|
|
120
|
+
const f = avatars.parsePeerAvatarFailed({ peer_node_uid: '200', reason: '404' });
|
|
121
|
+
assert.strictEqual(f.peer_node_uid, 200n);
|
|
122
|
+
assert.strictEqual(f.reason, '404');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('signaling.SignalingClient defaults capabilities to all-false', () => {
|
|
126
|
+
// Constructed without a websocket — fine for testing the field shape only.
|
|
127
|
+
const signaling = require('../signaling.js');
|
|
128
|
+
// SignalingClient isn't exported; reach into the module like other tests do.
|
|
129
|
+
const fs = require('node:fs');
|
|
130
|
+
const path = require('node:path');
|
|
131
|
+
const Module = require('node:module');
|
|
132
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'signaling.js'), 'utf8');
|
|
133
|
+
const m = new Module(require.resolve('../signaling.js'));
|
|
134
|
+
m.filename = require.resolve('../signaling.js');
|
|
135
|
+
m.paths = Module._nodeModulePaths(m.filename);
|
|
136
|
+
m._compile(src + '\nmodule.exports._SignalingClient = SignalingClient;\n', m.filename);
|
|
137
|
+
const SignalingClient = m.exports._SignalingClient;
|
|
138
|
+
const c = new SignalingClient('1.2.3.4', /* ws */ null, 1n);
|
|
139
|
+
assert.deepStrictEqual(c.capabilities, { avatar_relay: false });
|
|
140
|
+
});
|