Something in the Chamfer E2E tests is complaining about an uncaught error with `error_code` trying to be referenced on something that is `undefined`. That means that an API request is coming back as not successful but does not have an actual error in the body of the response.
2276 lines
74 KiB
TypeScript
2276 lines
74 KiB
TypeScript
import { TEST } from '@src/env'
|
||
import type { Models } from '@kittycad/lib'
|
||
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from '@src/env'
|
||
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
|
||
import { BSON } from 'bson'
|
||
|
||
import type { MachineManager } from '@src/components/MachineManagerProvider'
|
||
import type { useModelingContext } from '@src/hooks/useModelingContext'
|
||
import type { KclManager } from '@src/lang/KclSingleton'
|
||
import type CodeManager from '@src/lang/codeManager'
|
||
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
|
||
import type { EngineCommand, ResponseMap } from '@src/lang/std/artifactGraph'
|
||
import type { CommandLog } from '@src/lang/std/commandLog'
|
||
import { CommandLogType } from '@src/lang/std/commandLog'
|
||
import type { SourceRange } from '@src/lang/wasm'
|
||
import { defaultSourceRange } from '@src/lang/wasm'
|
||
import { EXECUTE_AST_INTERRUPT_ERROR_MESSAGE } from '@src/lib/constants'
|
||
import { markOnce } from '@src/lib/performance'
|
||
import type RustContext from '@src/lib/rustContext'
|
||
import type { SettingsViaQueryString } from '@src/lib/settings/settingsTypes'
|
||
import {
|
||
Themes,
|
||
darkModeMatcher,
|
||
getOppositeTheme,
|
||
getThemeColorForEngine,
|
||
} from '@src/lib/theme'
|
||
import { reportRejection } from '@src/lib/trap'
|
||
import { binaryToUuid, isArray, uuidv4 } from '@src/lib/utils'
|
||
|
||
const pingIntervalMs = 1_000
|
||
|
||
function isHighlightSetEntity_type(
|
||
data: any
|
||
): data is Models['HighlightSetEntity_type'] {
|
||
return data.entity_id && data.sequence
|
||
}
|
||
|
||
interface NewTrackArgs {
|
||
conn: EngineConnection
|
||
mediaStream: MediaStream
|
||
}
|
||
|
||
type ClientMetrics = Models['ClientMetrics_type']
|
||
|
||
type Value<T, U> = U extends undefined
|
||
? { type: T; value: U }
|
||
: U extends void
|
||
? { type: T }
|
||
: { type: T; value: U }
|
||
|
||
type State<T, U> = Value<T, U>
|
||
|
||
export enum EngineConnectionStateType {
|
||
Fresh = 'fresh',
|
||
Connecting = 'connecting',
|
||
ConnectionEstablished = 'connection-established',
|
||
Disconnecting = 'disconnecting',
|
||
Disconnected = 'disconnected',
|
||
}
|
||
|
||
export enum DisconnectingType {
|
||
Error = 'error',
|
||
Timeout = 'timeout',
|
||
Quit = 'quit',
|
||
Pause = 'pause',
|
||
}
|
||
|
||
// Sorted by severity
|
||
export enum ConnectionError {
|
||
Unset = 0,
|
||
LongLoadingTime,
|
||
VeryLongLoadingTime,
|
||
|
||
ICENegotiate,
|
||
DataChannelError,
|
||
WebSocketError,
|
||
LocalDescriptionInvalid,
|
||
|
||
// These are more severe than protocol errors because they don't even allow
|
||
// the program to do any protocol messages in the first place if they occur.
|
||
MissingAuthToken,
|
||
BadAuthToken,
|
||
TooManyConnections,
|
||
Outage,
|
||
|
||
// An unknown error is the most severe because it has not been classified
|
||
// or encountered before.
|
||
Unknown,
|
||
}
|
||
|
||
export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
|
||
[ConnectionError.Unset]: '',
|
||
[ConnectionError.LongLoadingTime]:
|
||
'Loading is taking longer than expected...',
|
||
[ConnectionError.VeryLongLoadingTime]:
|
||
'Loading seems stuck. Do you have a firewall turned on?',
|
||
[ConnectionError.ICENegotiate]: 'ICE negotiation failed.',
|
||
[ConnectionError.DataChannelError]: 'The data channel signaled an error.',
|
||
[ConnectionError.WebSocketError]: 'The websocket signaled an error.',
|
||
[ConnectionError.LocalDescriptionInvalid]:
|
||
'The local description is invalid.',
|
||
[ConnectionError.MissingAuthToken]:
|
||
'Your authorization token is missing; please login again.',
|
||
[ConnectionError.BadAuthToken]:
|
||
'Your authorization token is invalid; please login again.',
|
||
[ConnectionError.TooManyConnections]:
|
||
'There are too many open engine connections associated with your account.',
|
||
[ConnectionError.Outage]:
|
||
'We seem to be experiencing an outage. Please visit [status.zoo.dev](https://status.zoo.dev) for updates.',
|
||
[ConnectionError.Unknown]:
|
||
'An unexpected error occurred. Please report this to us.',
|
||
}
|
||
|
||
export const WEBSOCKET_READYSTATE_TEXT: Record<number, string> = {
|
||
[WebSocket.CONNECTING]: 'WebSocket.CONNECTING',
|
||
[WebSocket.OPEN]: 'WebSocket.OPEN',
|
||
[WebSocket.CLOSING]: 'WebSocket.CLOSING',
|
||
[WebSocket.CLOSED]: 'WebSocket.CLOSED',
|
||
}
|
||
|
||
export interface ErrorType {
|
||
// The error we've encountered.
|
||
error: ConnectionError
|
||
|
||
// Additional context.
|
||
context?: any
|
||
|
||
// We assign this in the state setter because we may have not failed at
|
||
// a Connecting state, which we check for there.
|
||
lastConnectingValue?: ConnectingValue
|
||
}
|
||
|
||
export type DisconnectingValue =
|
||
| State<DisconnectingType.Error, ErrorType>
|
||
| State<DisconnectingType.Timeout, void>
|
||
| State<DisconnectingType.Quit, void>
|
||
| State<DisconnectingType.Pause, void>
|
||
|
||
// These are ordered by the expected sequence.
|
||
export enum ConnectingType {
|
||
WebSocketConnecting = 'websocket-connecting',
|
||
WebSocketOpen = 'websocket-open',
|
||
PeerConnectionCreated = 'peer-connection-created',
|
||
ICEServersSet = 'ice-servers-set',
|
||
SetLocalDescription = 'set-local-description',
|
||
OfferedSdp = 'offered-sdp',
|
||
ReceivedSdp = 'received-sdp',
|
||
SetRemoteDescription = 'set-remote-description',
|
||
WebRTCConnecting = 'webrtc-connecting',
|
||
ICECandidateReceived = 'ice-candidate-received',
|
||
TrackReceived = 'track-received',
|
||
DataChannelRequested = 'data-channel-requested',
|
||
DataChannelConnecting = 'data-channel-connecting',
|
||
DataChannelEstablished = 'data-channel-established',
|
||
}
|
||
|
||
export enum ConnectingTypeGroup {
|
||
WebSocket = 'WebSocket',
|
||
ICE = 'ICE',
|
||
WebRTC = 'WebRTC',
|
||
}
|
||
|
||
export const initialConnectingTypeGroupState: Record<
|
||
ConnectingTypeGroup,
|
||
[ConnectingType, boolean | undefined][]
|
||
> = {
|
||
[ConnectingTypeGroup.WebSocket]: [
|
||
[ConnectingType.WebSocketConnecting, undefined],
|
||
[ConnectingType.WebSocketOpen, undefined],
|
||
],
|
||
[ConnectingTypeGroup.ICE]: [
|
||
[ConnectingType.PeerConnectionCreated, undefined],
|
||
[ConnectingType.ICEServersSet, undefined],
|
||
[ConnectingType.SetLocalDescription, undefined],
|
||
[ConnectingType.OfferedSdp, undefined],
|
||
[ConnectingType.ReceivedSdp, undefined],
|
||
[ConnectingType.SetRemoteDescription, undefined],
|
||
[ConnectingType.WebRTCConnecting, undefined],
|
||
[ConnectingType.ICECandidateReceived, undefined],
|
||
],
|
||
[ConnectingTypeGroup.WebRTC]: [
|
||
[ConnectingType.TrackReceived, undefined],
|
||
[ConnectingType.DataChannelRequested, undefined],
|
||
[ConnectingType.DataChannelConnecting, undefined],
|
||
[ConnectingType.DataChannelEstablished, undefined],
|
||
],
|
||
}
|
||
|
||
export type ConnectingValue =
|
||
| State<ConnectingType.WebSocketConnecting, void>
|
||
| State<ConnectingType.WebSocketOpen, void>
|
||
| State<ConnectingType.PeerConnectionCreated, void>
|
||
| State<ConnectingType.ICEServersSet, void>
|
||
| State<ConnectingType.SetLocalDescription, void>
|
||
| State<ConnectingType.OfferedSdp, void>
|
||
| State<ConnectingType.ReceivedSdp, void>
|
||
| State<ConnectingType.SetRemoteDescription, void>
|
||
| State<ConnectingType.WebRTCConnecting, void>
|
||
| State<ConnectingType.TrackReceived, void>
|
||
| State<ConnectingType.ICECandidateReceived, void>
|
||
| State<ConnectingType.DataChannelRequested, string>
|
||
| State<ConnectingType.DataChannelConnecting, string>
|
||
| State<ConnectingType.DataChannelEstablished, void>
|
||
|
||
export type EngineConnectionState =
|
||
| State<EngineConnectionStateType.Fresh, void>
|
||
| State<EngineConnectionStateType.Connecting, ConnectingValue>
|
||
| State<EngineConnectionStateType.ConnectionEstablished, void>
|
||
| State<EngineConnectionStateType.Disconnecting, DisconnectingValue>
|
||
| State<EngineConnectionStateType.Disconnected, void>
|
||
|
||
export enum EngineConnectionEvents {
|
||
// Fires for each ping-pong success or failure.
|
||
PingPongChanged = 'ping-pong-changed', // (state: PingPongState) => void
|
||
|
||
// For now, this is only used by the NetworkHealthIndicator.
|
||
// We can eventually use it for more, but one step at a time.
|
||
ConnectionStateChanged = 'connection-state-changed', // (state: EngineConnectionState) => void
|
||
|
||
// There are various failure scenarios where we want to try a restart.
|
||
RestartRequest = 'restart-request',
|
||
|
||
// These are used for the EngineCommandManager and were created
|
||
// before onConnectionStateChange existed.
|
||
ConnectionStarted = 'connection-started', // (engineConnection: EngineConnection) => void
|
||
Opened = 'opened', // (engineConnection: EngineConnection) => void
|
||
Closed = 'closed', // (engineConnection: EngineConnection) => void
|
||
NewTrack = 'new-track', // (track: NewTrackArgs) => void
|
||
}
|
||
|
||
function toRTCSessionDescriptionInit(
|
||
desc: Models['RtcSessionDescription_type']
|
||
): RTCSessionDescriptionInit | undefined {
|
||
if (desc.type === 'unspecified') {
|
||
console.error('Invalid SDP answer: type is "unspecified".')
|
||
return undefined
|
||
}
|
||
return {
|
||
sdp: desc.sdp,
|
||
// Force the type to be one of the valid RTCSdpType values
|
||
type: desc.type as RTCSdpType,
|
||
}
|
||
}
|
||
|
||
// EngineConnection encapsulates the connection(s) to the Engine
|
||
// for the EngineCommandManager; namely, the underlying WebSocket
|
||
// and WebRTC connections.
|
||
class EngineConnection extends EventTarget {
|
||
websocket?: WebSocket
|
||
pc?: RTCPeerConnection
|
||
unreliableDataChannel?: RTCDataChannel
|
||
mediaStream?: MediaStream
|
||
promise?: Promise<void>
|
||
sdpAnswer?: RTCSessionDescriptionInit
|
||
triggeredStart = false
|
||
|
||
onWebSocketOpen = function (event: Event) {}
|
||
onWebSocketClose = function (event: Event) {}
|
||
onWebSocketError = function (event: Event) {}
|
||
onWebSocketMessage = function (event: MessageEvent) {}
|
||
onIceGatheringStateChange = function (
|
||
this: RTCPeerConnection,
|
||
event: Event
|
||
) {}
|
||
onIceConnectionStateChange = function (
|
||
this: RTCPeerConnection,
|
||
event: Event
|
||
) {}
|
||
onNegotiationNeeded = function (this: RTCPeerConnection, event: Event) {}
|
||
onIceCandidate = function (
|
||
this: RTCPeerConnection,
|
||
event: RTCPeerConnectionIceEvent
|
||
) {}
|
||
onIceCandidateError = function (
|
||
this: RTCPeerConnection,
|
||
event: RTCPeerConnectionIceErrorEvent
|
||
) {}
|
||
onConnectionStateChange = function (this: RTCPeerConnection, event: Event) {}
|
||
onSignalingStateChange = function (this: RTCDataChannel, event: Event) {}
|
||
|
||
onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {}
|
||
onDataChannelOpen = function (this: RTCDataChannel, event: Event) {}
|
||
onDataChannelClose = function (this: RTCDataChannel, event: Event) {}
|
||
onDataChannelError = function (this: RTCDataChannel, event: Event) {}
|
||
onDataChannelMessage = function (this: RTCDataChannel, event: MessageEvent) {}
|
||
onDataChannel = function (
|
||
this: RTCPeerConnection,
|
||
event: RTCDataChannelEvent
|
||
) {}
|
||
|
||
onNetworkStatusReady = () => {}
|
||
|
||
private _state: EngineConnectionState = {
|
||
type: EngineConnectionStateType.Fresh,
|
||
}
|
||
|
||
get state(): EngineConnectionState {
|
||
return this._state
|
||
}
|
||
|
||
set state(next: EngineConnectionState) {
|
||
console.log(`${JSON.stringify(this.state)} → ${JSON.stringify(next)}`)
|
||
|
||
if (next.type === EngineConnectionStateType.Disconnecting) {
|
||
const sub = next.value
|
||
if (sub.type === DisconnectingType.Error) {
|
||
// Record the last step we failed at.
|
||
// (Check the current state that we're about to override that
|
||
// it was a Connecting state.)
|
||
if (this._state.type === EngineConnectionStateType.Connecting) {
|
||
if (!sub.value) sub.value = { error: ConnectionError.Unknown }
|
||
sub.value.lastConnectingValue = this._state.value
|
||
}
|
||
|
||
console.error(sub.value)
|
||
}
|
||
}
|
||
this._state = next
|
||
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.ConnectionStateChanged, {
|
||
detail: this._state,
|
||
})
|
||
)
|
||
}
|
||
|
||
readonly url: string
|
||
private readonly token?: string
|
||
|
||
// TODO: actual type is ClientMetrics
|
||
public webrtcStatsCollector?: () => Promise<ClientMetrics>
|
||
private engineCommandManager: EngineCommandManager
|
||
|
||
private pingPongSpan: { ping?: number; pong?: number }
|
||
private pingIntervalId: ReturnType<typeof setInterval> | undefined = undefined
|
||
isUsingConnectionLite: boolean = false
|
||
|
||
timeoutToForceConnectId: ReturnType<typeof setTimeout> | undefined = undefined
|
||
|
||
constructor({
|
||
engineCommandManager,
|
||
url,
|
||
token,
|
||
callbackOnEngineLiteConnect,
|
||
}: {
|
||
engineCommandManager: EngineCommandManager
|
||
url: string
|
||
token?: string
|
||
callbackOnEngineLiteConnect?: () => void
|
||
}) {
|
||
markOnce('code/startInitialEngineConnect')
|
||
super()
|
||
|
||
this.engineCommandManager = engineCommandManager
|
||
this.url = url
|
||
this.token = token
|
||
this.pingPongSpan = { ping: undefined, pong: undefined }
|
||
|
||
if (callbackOnEngineLiteConnect) {
|
||
this.connectLite(callbackOnEngineLiteConnect)
|
||
this.isUsingConnectionLite = true
|
||
return
|
||
}
|
||
|
||
this.pingIntervalId = setInterval(() => {
|
||
// Only start a new ping when the other is fulfilled.
|
||
if (this.pingPongSpan.ping) {
|
||
return
|
||
}
|
||
|
||
// Don't start pinging until we're connected.
|
||
if (this.state.type !== EngineConnectionStateType.ConnectionEstablished) {
|
||
return
|
||
}
|
||
|
||
this.send({ type: 'ping' })
|
||
this.pingPongSpan = {
|
||
ping: Date.now(),
|
||
pong: undefined,
|
||
}
|
||
}, pingIntervalMs)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.connect({ reconnect: false })
|
||
}
|
||
|
||
// SHOULD ONLY BE USED FOR VITESTS
|
||
connectLite(callback: () => void) {
|
||
const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${256}&video_res_height=${256}`
|
||
|
||
this.websocket = new WebSocket(url, [])
|
||
this.websocket.binaryType = 'arraybuffer'
|
||
|
||
this.send = (a) => {
|
||
if (!this.websocket) return
|
||
this.websocket.send(JSON.stringify(a))
|
||
}
|
||
this.onWebSocketOpen = (event) => {
|
||
this.send({
|
||
type: 'headers',
|
||
headers: {
|
||
Authorization: `Bearer ${VITE_KC_DEV_TOKEN}`,
|
||
},
|
||
})
|
||
}
|
||
this.tearDown = () => {}
|
||
this.websocket.addEventListener('open', this.onWebSocketOpen)
|
||
|
||
this.websocket?.addEventListener('message', ((event: MessageEvent) => {
|
||
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
|
||
if (!('resp' in message)) return
|
||
|
||
let resp = message.resp
|
||
|
||
// If there's no body to the response, we can bail here.
|
||
if (!resp || !resp.type) {
|
||
return
|
||
}
|
||
|
||
switch (resp.type) {
|
||
case 'pong':
|
||
break
|
||
|
||
// Only fires on successful authentication.
|
||
case 'ice_server_info':
|
||
callback()
|
||
return
|
||
}
|
||
|
||
this.engineCommandManager.handleMessage(event)
|
||
}) as EventListener)
|
||
}
|
||
|
||
isConnecting() {
|
||
return this.state.type === EngineConnectionStateType.Connecting
|
||
}
|
||
|
||
isReady() {
|
||
return this.state.type === EngineConnectionStateType.ConnectionEstablished
|
||
}
|
||
|
||
tearDown() {
|
||
clearInterval(this.pingIntervalId)
|
||
clearTimeout(this.timeoutToForceConnectId)
|
||
|
||
// As each network connection (websocket, webrtc, peer connection) is
|
||
// closed, they will handle removing their own event listeners.
|
||
// If they didn't then it'd be possible we stop listened to close events
|
||
// which is what we want to do in the first place :)
|
||
|
||
this.disconnectAll()
|
||
|
||
if (this.engineCommandManager.idleMode) {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Pause,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Pass the state along
|
||
if (this.state.type === EngineConnectionStateType.Disconnecting) return
|
||
if (this.state.type === EngineConnectionStateType.Disconnected) return
|
||
|
||
// Otherwise it's by default a "quit"
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Quit,
|
||
},
|
||
}
|
||
}
|
||
|
||
initiateConnectionExclusive(): boolean {
|
||
// Only run if:
|
||
// - A peer connection exists,
|
||
// - ICE gathering is complete,
|
||
// - We have an SDP answer,
|
||
// - And we haven’t already triggered this connection.
|
||
if (!this.pc || this.triggeredStart || !this.sdpAnswer) {
|
||
return false
|
||
}
|
||
this.triggeredStart = true
|
||
|
||
// Transition to the connecting state
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: { type: ConnectingType.WebRTCConnecting },
|
||
}
|
||
|
||
// Attempt to set the remote description to initiate connection
|
||
this.pc
|
||
.setRemoteDescription(this.sdpAnswer)
|
||
.then(() => {
|
||
// Update state once the remote description has been set
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: { type: ConnectingType.SetRemoteDescription },
|
||
}
|
||
})
|
||
.catch((error: Error) => {
|
||
console.error('Failed to set remote description:', error)
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.LocalDescriptionInvalid,
|
||
context: error,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
})
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Attempts to connect to the Engine over a WebSocket, and
|
||
* establish the WebRTC connections.
|
||
*
|
||
* This will attempt the full handshake, and retry if the connection
|
||
* did not establish.
|
||
*/
|
||
connect(args: { reconnect: boolean }): Promise<void> {
|
||
// eslint-disable-next-line
|
||
const that = this
|
||
return new Promise((resolve) => {
|
||
if (this.isConnecting() || this.isReady()) {
|
||
return
|
||
}
|
||
|
||
const createPeerConnection = () => {
|
||
this.pc = new RTCPeerConnection({
|
||
bundlePolicy: 'max-bundle',
|
||
})
|
||
|
||
// Other parts of the application expect pc to be initialized when firing.
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
|
||
detail: this,
|
||
})
|
||
)
|
||
|
||
// Data channels MUST BE specified before SDP offers because requesting
|
||
// them affects what our needs are!
|
||
const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds'
|
||
this.pc?.createDataChannel?.(DATACHANNEL_NAME_UMC)
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.DataChannelRequested,
|
||
value: DATACHANNEL_NAME_UMC,
|
||
},
|
||
}
|
||
|
||
this.onIceCandidate = (event: RTCPeerConnectionIceEvent) => {
|
||
console.log('icecandidate', event.candidate)
|
||
|
||
// This is null when the ICE gathering state is done.
|
||
// Windows ONLY uses this to signal it's done!
|
||
if (event.candidate === null) {
|
||
that.initiateConnectionExclusive()
|
||
return
|
||
}
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.ICECandidateReceived,
|
||
},
|
||
}
|
||
|
||
this.send({
|
||
type: 'trickle_ice',
|
||
candidate: {
|
||
candidate: event.candidate.candidate,
|
||
sdpMid: event.candidate.sdpMid || undefined,
|
||
sdpMLineIndex: event.candidate.sdpMLineIndex || undefined,
|
||
usernameFragment: event.candidate.usernameFragment || undefined,
|
||
},
|
||
})
|
||
|
||
// Sometimes the remote end doesn't report the end of candidates.
|
||
// They have 3 seconds to.
|
||
this.timeoutToForceConnectId = setTimeout(() => {
|
||
if (that.initiateConnectionExclusive()) {
|
||
console.warn('connected after 3 second delay')
|
||
}
|
||
}, 3000)
|
||
}
|
||
this.pc?.addEventListener?.('icecandidate', this.onIceCandidate)
|
||
|
||
// Watch out human! The names of the next couple events are really similar!
|
||
this.onIceGatheringStateChange = (event) => {
|
||
console.log('icegatheringstatechange', event)
|
||
|
||
that.initiateConnectionExclusive()
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'icegatheringstatechange',
|
||
this.onIceGatheringStateChange
|
||
)
|
||
|
||
this.onIceConnectionStateChange = (event: Event) => {
|
||
console.log('iceconnectionstatechange', event)
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'iceconnectionstatechange',
|
||
this.onIceConnectionStateChange
|
||
)
|
||
|
||
this.onNegotiationNeeded = (event: Event) => {
|
||
console.log('negotiationneeded', event)
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'negotiationneeded',
|
||
this.onNegotiationNeeded
|
||
)
|
||
|
||
this.onSignalingStateChange = (event) => {
|
||
console.log('signalingstatechange', event)
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'signalingstatechange',
|
||
this.onSignalingStateChange
|
||
)
|
||
|
||
this.onIceCandidateError = (_event: Event) => {
|
||
const event = _event as RTCPeerConnectionIceErrorEvent
|
||
console.warn(
|
||
`ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}`
|
||
)
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'icecandidateerror',
|
||
this.onIceCandidateError
|
||
)
|
||
|
||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
|
||
// Event type: generic Event type...
|
||
this.onConnectionStateChange = (event: any) => {
|
||
console.log('connectionstatechange: ' + event.target?.connectionState)
|
||
switch (event.target?.connectionState) {
|
||
// From what I understand, only after have we done the ICE song and
|
||
// dance is it safest to connect the video tracks / stream
|
||
case 'connected':
|
||
// Let the browser attach to the video stream now
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.NewTrack, {
|
||
detail: { conn: this, mediaStream: this.mediaStream! },
|
||
})
|
||
)
|
||
break
|
||
|
||
case 'connecting':
|
||
break
|
||
|
||
case 'failed':
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.ICENegotiate,
|
||
context: event,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
break
|
||
|
||
// The remote end broke up with us! :(
|
||
case 'disconnected':
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
|
||
)
|
||
break
|
||
case 'closed':
|
||
this.pc?.removeEventListener('icecandidate', this.onIceCandidate)
|
||
this.pc?.removeEventListener(
|
||
'icegatheringstatechange',
|
||
this.onIceGatheringStateChange
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'iceconnectionstatechange',
|
||
this.onIceConnectionStateChange
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'negotiationneeded',
|
||
this.onNegotiationNeeded
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'signalingstatechange',
|
||
this.onSignalingStateChange
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'icecandidateerror',
|
||
this.onIceCandidateError
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'connectionstatechange',
|
||
this.onConnectionStateChange
|
||
)
|
||
this.pc?.removeEventListener('track', this.onTrack)
|
||
this.pc?.removeEventListener('datachannel', this.onDataChannel)
|
||
break
|
||
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
this.pc?.addEventListener?.(
|
||
'connectionstatechange',
|
||
this.onConnectionStateChange
|
||
)
|
||
|
||
this.onTrack = (event) => {
|
||
const mediaStream = event.streams[0]
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.TrackReceived,
|
||
},
|
||
}
|
||
|
||
this.webrtcStatsCollector = (): Promise<ClientMetrics> => {
|
||
return new Promise((resolve, reject) => {
|
||
if (mediaStream.getVideoTracks().length !== 1) {
|
||
reject(new Error('too many video tracks to report'))
|
||
return
|
||
}
|
||
const inboundVideoTrack = mediaStream.getVideoTracks()[0]
|
||
|
||
void this.pc?.getStats().then((stats) => {
|
||
let metrics: ClientMetrics = {}
|
||
|
||
stats.forEach((report, id) => {
|
||
if (report.type === 'candidate-pair') {
|
||
if (report.state == 'succeeded') {
|
||
const rtt = report.currentRoundTripTime
|
||
metrics.rtc_stun_rtt_sec = rtt
|
||
}
|
||
} else if (report.type === 'inbound-rtp') {
|
||
// TODO(paultag): Since we can technically have multiple WebRTC
|
||
// video tracks (even if the Server doesn't at the moment), we
|
||
// ought to send stats for every video track(?), and add the stream
|
||
// ID into it. This raises the cardinality of collected metrics
|
||
// when/if we do.
|
||
// For now we just take one of the video tracks.
|
||
if (report.trackIdentifier !== inboundVideoTrack.id) {
|
||
return
|
||
}
|
||
|
||
metrics.rtc_frames_decoded = report.framesDecoded
|
||
metrics.rtc_frames_dropped = report.framesDropped
|
||
metrics.rtc_frames_received = report.framesReceived
|
||
metrics.rtc_frames_per_second = report.framesPerSecond
|
||
metrics.rtc_freeze_count = report.freezeCount
|
||
metrics.rtc_jitter_sec = report.jitter
|
||
metrics.rtc_keyframes_decoded = report.keyFramesDecoded
|
||
metrics.rtc_total_freezes_duration_sec =
|
||
report.totalFreezesDuration
|
||
metrics.rtc_frame_height = report.frameHeight
|
||
metrics.rtc_frame_width = report.frameWidth
|
||
metrics.rtc_packets_lost = report.packetsLost
|
||
metrics.rtc_pli_count = report.pliCount
|
||
}
|
||
// The following report types exist, but are unused:
|
||
// data-channel, transport, certificate, peer-connection, local-candidate, remote-candidate, codec
|
||
})
|
||
|
||
resolve(metrics)
|
||
})
|
||
})
|
||
}
|
||
|
||
// The app is eager to use the MediaStream; as soon as onNewTrack is
|
||
// called, the following sequence happens:
|
||
// EngineConnection.onNewTrack -> StoreState.setMediaStream ->
|
||
// EngineStream.tsx reacts to mediaStream change, setting a video element.
|
||
|
||
this.mediaStream = mediaStream
|
||
}
|
||
this.pc?.addEventListener?.('track', this.onTrack)
|
||
|
||
this.onDataChannel = (event) => {
|
||
this.unreliableDataChannel = event.channel
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.DataChannelConnecting,
|
||
value: event.channel.label,
|
||
},
|
||
}
|
||
|
||
this.onDataChannelOpen = (event) => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.DataChannelEstablished,
|
||
},
|
||
}
|
||
|
||
// Start firing off engine commands at this point.
|
||
// They could be fired at an earlier time, onWebSocketOpen,
|
||
// but DataChannel can offer some benefits like speed,
|
||
// and it's nice to say everything's connected before interacting
|
||
// with the server.
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.ConnectionEstablished,
|
||
}
|
||
|
||
this.engineCommandManager.inSequence = 1
|
||
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.Opened, {
|
||
detail: this,
|
||
})
|
||
)
|
||
markOnce('code/endInitialEngineConnect')
|
||
}
|
||
this.unreliableDataChannel?.addEventListener(
|
||
'open',
|
||
this.onDataChannelOpen
|
||
)
|
||
|
||
this.onDataChannelClose = (event) => {
|
||
this.unreliableDataChannel?.removeEventListener(
|
||
'open',
|
||
this.onDataChannelOpen
|
||
)
|
||
this.unreliableDataChannel?.removeEventListener(
|
||
'close',
|
||
this.onDataChannelClose
|
||
)
|
||
this.unreliableDataChannel?.removeEventListener(
|
||
'error',
|
||
this.onDataChannelError
|
||
)
|
||
this.unreliableDataChannel?.removeEventListener(
|
||
'message',
|
||
this.onDataChannelMessage
|
||
)
|
||
this.disconnectAll()
|
||
}
|
||
|
||
this.unreliableDataChannel?.addEventListener(
|
||
'close',
|
||
this.onDataChannelClose
|
||
)
|
||
|
||
this.onDataChannelError = (event) => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.DataChannelError,
|
||
context: event,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
}
|
||
this.unreliableDataChannel?.addEventListener(
|
||
'error',
|
||
this.onDataChannelError
|
||
)
|
||
|
||
this.onDataChannelMessage = (event) => {
|
||
const result: UnreliableResponses = JSON.parse(event.data)
|
||
Object.values(
|
||
this.engineCommandManager.unreliableSubscriptions[result.type] ||
|
||
{}
|
||
).forEach(
|
||
// TODO: There is only one response that uses the unreliable channel atm,
|
||
// highlight_set_entity, if there are more it's likely they will all have the same
|
||
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
|
||
// per unreliable subscription.
|
||
(callback) => {
|
||
if (
|
||
result.type === 'highlight_set_entity' &&
|
||
result?.data?.sequence &&
|
||
result?.data.sequence > this.engineCommandManager.inSequence
|
||
) {
|
||
this.engineCommandManager.inSequence = result.data.sequence
|
||
callback(result)
|
||
} else if (result.type !== 'highlight_set_entity') {
|
||
callback(result)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
this.unreliableDataChannel.addEventListener(
|
||
'message',
|
||
this.onDataChannelMessage
|
||
)
|
||
}
|
||
this.pc?.addEventListener?.('datachannel', this.onDataChannel)
|
||
}
|
||
|
||
const createWebSocketConnection = () => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.WebSocketConnecting,
|
||
},
|
||
}
|
||
|
||
this.websocket = new WebSocket(this.url, [])
|
||
this.websocket.binaryType = 'arraybuffer'
|
||
|
||
this.onWebSocketOpen = (event) => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.WebSocketOpen,
|
||
},
|
||
}
|
||
|
||
// This is required for when the app is running stand-alone / within desktop app.
|
||
// Otherwise when run in a browser, the token is sent implicitly via
|
||
// the Cookie header.
|
||
if (this.token) {
|
||
this.send({
|
||
type: 'headers',
|
||
headers: {
|
||
Authorization: `Bearer ${this.token}`,
|
||
},
|
||
})
|
||
}
|
||
|
||
// Send an initial ping
|
||
this.send({ type: 'ping' })
|
||
this.pingPongSpan.ping = Date.now()
|
||
}
|
||
this.websocket.addEventListener('open', this.onWebSocketOpen)
|
||
|
||
this.onWebSocketClose = (event) => {
|
||
this.websocket?.removeEventListener('open', this.onWebSocketOpen)
|
||
this.websocket?.removeEventListener('close', this.onWebSocketClose)
|
||
this.websocket?.removeEventListener('error', this.onWebSocketError)
|
||
this.websocket?.removeEventListener(
|
||
'message',
|
||
this.onWebSocketMessage
|
||
)
|
||
|
||
window.removeEventListener(
|
||
'use-network-status-ready',
|
||
this.onNetworkStatusReady
|
||
)
|
||
|
||
this.disconnectAll()
|
||
}
|
||
this.websocket.addEventListener('close', this.onWebSocketClose)
|
||
|
||
this.onWebSocketError = (event: Event) => {
|
||
if (event.target instanceof WebSocket) {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.WebSocketError,
|
||
context:
|
||
WEBSOCKET_READYSTATE_TEXT[event.target.readyState] ?? event,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
this.disconnectAll()
|
||
}
|
||
this.websocket.addEventListener('error', this.onWebSocketError)
|
||
|
||
this.onWebSocketMessage = (event) => {
|
||
// In the EngineConnection, we're looking for messages to/from
|
||
// the server that relate to the ICE handshake, or WebRTC
|
||
// negotiation. There may be other messages (including ArrayBuffer
|
||
// messages) that are intended for the GUI itself, so be careful
|
||
// when assuming we're the only consumer or that all messages will
|
||
// be carefully formatted here.
|
||
|
||
if (typeof event.data !== 'string') {
|
||
return
|
||
}
|
||
|
||
const message: Models['WebSocketResponse_type'] = JSON.parse(
|
||
event.data
|
||
)
|
||
|
||
if (!message.success) {
|
||
const errorsString = message?.errors
|
||
?.map((error) => {
|
||
return ` - ${error?.error_code || 'Unknown error'}: ${error?.message || 'Unknown message'}`
|
||
})
|
||
.join('\n')
|
||
if (message.request_id) {
|
||
const pendingCommand =
|
||
this.engineCommandManager.pendingCommands[message.request_id]
|
||
console.error(
|
||
`Error in response to request ${message.request_id}:\n${errorsString}\n\nPending command:\n${JSON.stringify(
|
||
pendingCommand,
|
||
null,
|
||
2
|
||
)}`
|
||
)
|
||
} else {
|
||
console.error(`Error from server:\n${errorsString}`)
|
||
}
|
||
|
||
const firstError = message?.errors[0]
|
||
if (firstError?.error_code === 'auth_token_invalid') {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.BadAuthToken,
|
||
context: firstError.message,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
}
|
||
|
||
if (firstError?.error_code === 'internal_api') {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.Outage,
|
||
context: firstError.message,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
}
|
||
return
|
||
}
|
||
|
||
let resp = message.resp
|
||
|
||
// If there's no body to the response, we can bail here.
|
||
if (!resp || !resp.type) {
|
||
return
|
||
}
|
||
|
||
switch (resp.type) {
|
||
case 'pong':
|
||
this.pingPongSpan.pong = Date.now()
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
||
detail: Math.min(
|
||
999,
|
||
Math.floor(
|
||
this.pingPongSpan.pong - (this.pingPongSpan.ping ?? 0)
|
||
)
|
||
),
|
||
})
|
||
)
|
||
// Clear the initial ping so our interval ping loop can fire again
|
||
// But only after using it above!
|
||
this.pingPongSpan.ping = undefined
|
||
break
|
||
|
||
case 'modeling_session_data':
|
||
let api_call_id = resp.data?.session?.api_call_id
|
||
console.log(`API Call ID: ${api_call_id}`)
|
||
break
|
||
|
||
// Only fires on successful authentication.
|
||
case 'ice_server_info':
|
||
let ice_servers = resp.data?.ice_servers
|
||
|
||
// Now that we have some ICE servers it makes sense
|
||
// to start initializing the RTCPeerConnection. RTCPeerConnection
|
||
// will begin the ICE process.
|
||
createPeerConnection()
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.PeerConnectionCreated,
|
||
},
|
||
}
|
||
|
||
// No ICE servers can be valid in a local dev. env.
|
||
if (ice_servers?.length === 0) {
|
||
console.warn('No ICE servers')
|
||
this.pc?.setConfiguration({
|
||
bundlePolicy: 'max-bundle',
|
||
})
|
||
} else {
|
||
// When we set the Configuration, we want to always force
|
||
// iceTransportPolicy to 'relay', since we know the topology
|
||
// of the ICE/STUN/TUN server and the engine. We don't wish to
|
||
// talk to the engine in any configuration /other/ than relay
|
||
// from a infra POV.
|
||
this.pc?.setConfiguration({
|
||
bundlePolicy: 'max-bundle',
|
||
iceServers: ice_servers,
|
||
iceTransportPolicy: 'relay',
|
||
})
|
||
}
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.ICEServersSet,
|
||
},
|
||
}
|
||
|
||
// We have an ICE Servers set now. We just setConfiguration, so let's
|
||
// start adding things we care about to the PeerConnection and let
|
||
// ICE negotiation happen in the background. Everything from here
|
||
// until the end of this function is setup of our end of the
|
||
// PeerConnection and waiting for events to fire our callbacks.
|
||
|
||
// Add a transceiver to our SDP offer
|
||
this.pc?.addTransceiver('video', {
|
||
direction: 'recvonly',
|
||
})
|
||
|
||
// Create a session description offer based on our local environment
|
||
// that we will send to the remote end. The remote will send back
|
||
// what it supports via sdp_answer.
|
||
this.pc
|
||
?.createOffer()
|
||
.then((offer: RTCSessionDescriptionInit) => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.SetLocalDescription,
|
||
},
|
||
}
|
||
return this.pc?.setLocalDescription(offer).then(() => {
|
||
this.send({
|
||
type: 'sdp_offer',
|
||
offer: offer as Models['RtcSessionDescription_type'],
|
||
})
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.OfferedSdp,
|
||
},
|
||
}
|
||
})
|
||
})
|
||
.catch((err: Error) => {
|
||
// The local description is invalid, so there's no point continuing.
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.LocalDescriptionInvalid,
|
||
context: err,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
})
|
||
break
|
||
|
||
case 'sdp_answer':
|
||
let answer = resp.data?.answer
|
||
if (!answer || answer.type === 'unspecified') {
|
||
return
|
||
}
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Connecting,
|
||
value: {
|
||
type: ConnectingType.ReceivedSdp,
|
||
},
|
||
}
|
||
|
||
this.sdpAnswer = toRTCSessionDescriptionInit(answer)
|
||
|
||
// We might have received this after ice candidates finish
|
||
// Make sure we attempt to connect when we do.
|
||
this.initiateConnectionExclusive()
|
||
break
|
||
|
||
case 'trickle_ice':
|
||
let candidate = resp.data?.candidate
|
||
void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
|
||
break
|
||
|
||
case 'metrics_request':
|
||
if (this.webrtcStatsCollector === undefined) {
|
||
// TODO: Error message here?
|
||
return
|
||
}
|
||
void this.webrtcStatsCollector().then((client_metrics) => {
|
||
this.send({
|
||
type: 'metrics_response',
|
||
metrics: client_metrics,
|
||
})
|
||
})
|
||
break
|
||
}
|
||
}
|
||
this.websocket.addEventListener('message', this.onWebSocketMessage)
|
||
}
|
||
|
||
if (args.reconnect) {
|
||
createWebSocketConnection()
|
||
} else {
|
||
this.onNetworkStatusReady = () => {
|
||
createWebSocketConnection()
|
||
}
|
||
window.addEventListener(
|
||
'use-network-status-ready',
|
||
this.onNetworkStatusReady
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
// Do not change this back to an object or any, we should only be sending the
|
||
// WebSocketRequest type!
|
||
unreliableSend(message: Models['WebSocketRequest_type']) {
|
||
if (this.unreliableDataChannel?.readyState !== 'open') return
|
||
|
||
// TODO(paultag): Add in logic to determine the connection state and
|
||
// take actions if needed?
|
||
this.unreliableDataChannel?.send(
|
||
typeof message === 'string' ? message : JSON.stringify(message)
|
||
)
|
||
}
|
||
// Do not change this back to an object or any, we should only be sending the
|
||
// WebSocketRequest type!
|
||
send(message: Models['WebSocketRequest_type']) {
|
||
// Not connected, don't send anything
|
||
if (this.websocket?.readyState !== 1) return
|
||
|
||
// TODO(paultag): Add in logic to determine the connection state and
|
||
// take actions if needed?
|
||
this.websocket?.send(
|
||
typeof message === 'string' ? message : JSON.stringify(message)
|
||
)
|
||
}
|
||
disconnectAll() {
|
||
if (this.websocket && this.websocket?.readyState < 3) {
|
||
this.websocket?.close()
|
||
}
|
||
if (this.unreliableDataChannel?.readyState === 'open') {
|
||
this.unreliableDataChannel?.close()
|
||
}
|
||
if (this.pc?.connectionState === 'connected') {
|
||
this.pc?.close()
|
||
}
|
||
|
||
this.webrtcStatsCollector = undefined
|
||
|
||
// Already triggered
|
||
if (this.state.type === EngineConnectionStateType.Disconnected) return
|
||
|
||
const closedPc = !this.pc || this.pc?.connectionState === 'closed'
|
||
const closedUDC =
|
||
!this.unreliableDataChannel ||
|
||
this.unreliableDataChannel?.readyState === 'closed'
|
||
|
||
// Do not check when timing out because websockets take forever to
|
||
// report their disconnected state.
|
||
const closedWS =
|
||
(this.state.type === EngineConnectionStateType.Disconnecting &&
|
||
this.state.value.type === DisconnectingType.Timeout) ||
|
||
!this.websocket ||
|
||
this.websocket?.readyState === 3
|
||
|
||
if (!(closedPc && closedUDC && closedWS)) {
|
||
return
|
||
}
|
||
|
||
// Clean up all the event listeners.
|
||
|
||
if (!this.engineCommandManager.idleMode) {
|
||
// Do not notify the rest of the program that we have cut off anything.
|
||
this.state = { type: EngineConnectionStateType.Disconnected }
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
|
||
)
|
||
} else {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Pause,
|
||
},
|
||
}
|
||
}
|
||
this.triggeredStart = false
|
||
}
|
||
}
|
||
|
||
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
|
||
|
||
type UnreliableResponses = Extract<
|
||
Models['OkModelingCmdResponse_type'],
|
||
{ type: 'highlight_set_entity' | 'camera_drag_move' }
|
||
>
|
||
export interface UnreliableSubscription<T extends UnreliableResponses['type']> {
|
||
event: T
|
||
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
|
||
}
|
||
|
||
// TODO: Should eventually be replaced with native EventTarget event system,
|
||
// as it manages events in a more familiar way to other developers.
|
||
export interface Subscription<T extends ModelTypes> {
|
||
event: T
|
||
callback: (
|
||
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
|
||
) => void
|
||
}
|
||
|
||
export enum EngineCommandManagerEvents {
|
||
// engineConnection is available but scene setup may not have run
|
||
EngineAvailable = 'engine-available',
|
||
|
||
// request a restart of engineConnection
|
||
EngineRestartRequest = 'engine-restart-request',
|
||
|
||
// the whole scene is ready (settings loaded)
|
||
SceneReady = 'scene-ready',
|
||
}
|
||
|
||
/**
|
||
* The EngineCommandManager is the main interface to the Engine for Design Studio.
|
||
*
|
||
* It is responsible for sending commands to the Engine, and managing the state
|
||
* of those commands. It also sets up and tears down the connection to the Engine
|
||
* through the {@link EngineConnection} class.
|
||
*
|
||
* As commands are send their state is tracked in {@link pendingCommands} and clear as soon as we receive a response.
|
||
*
|
||
* Also all commands that are sent are kept track of in WASM and their responses are kept in {@link responseMap}
|
||
*/
|
||
|
||
interface PendingMessage {
|
||
command: EngineCommand
|
||
range: SourceRange
|
||
idToRangeMap: { [key: string]: SourceRange }
|
||
resolve: (data: [Models['WebSocketResponse_type']]) => void
|
||
// BOTH resolve and reject get passed back to the rust side which
|
||
// assumes it is this type! Do not change it!
|
||
// Format your errors as this type!
|
||
reject: (reason: [Models['WebSocketResponse_type']]) => void
|
||
promise: Promise<[Models['WebSocketResponse_type']]>
|
||
isSceneCommand: boolean
|
||
}
|
||
export class EngineCommandManager extends EventTarget {
|
||
/**
|
||
* The pendingCommands object is a map of the commands that have been sent to the engine that are still waiting on a reply
|
||
*/
|
||
pendingCommands: {
|
||
[commandId: string]: PendingMessage
|
||
} = {}
|
||
/**
|
||
* A map of the responses to the WASM, this response map allow
|
||
* us to look up the response by command id
|
||
*/
|
||
responseMap: ResponseMap = {}
|
||
/**
|
||
* A counter that is incremented with each command sent over the *unreliable* channel to the engine.
|
||
* This is compared to the latest received {@link inSequence} number to determine if we should ignore
|
||
* any out-of-order late responses in the unreliable channel.
|
||
*/
|
||
outSequence = 1
|
||
/**
|
||
* The latest sequence number received from the engine over the *unreliable* channel.
|
||
* This is compared to the {@link outSequence} number to determine if we should ignore
|
||
* any out-of-order late responses in the unreliable channel.
|
||
*/
|
||
inSequence = 1
|
||
engineConnection?: EngineConnection
|
||
commandLogs: CommandLog[] = []
|
||
settings: SettingsViaQueryString
|
||
|
||
streamDimensions = {
|
||
// Random defaults that are overwritten pretty much immediately
|
||
width: 1337,
|
||
height: 1337,
|
||
}
|
||
|
||
_commandLogCallBack: (command: CommandLog[]) => void = () => {}
|
||
|
||
subscriptions: {
|
||
[event: string]: {
|
||
[localUnsubscribeId: string]: (a: any) => void
|
||
}
|
||
} = {} as any
|
||
unreliableSubscriptions: {
|
||
[event: string]: {
|
||
[localUnsubscribeId: string]: (a: any) => void
|
||
}
|
||
} = {} as any
|
||
|
||
constructor(settings?: SettingsViaQueryString) {
|
||
super()
|
||
|
||
this.engineConnection = undefined
|
||
this.settings = settings
|
||
? settings
|
||
: {
|
||
pool: null,
|
||
theme: Themes.Dark,
|
||
highlightEdges: true,
|
||
enableSSAO: true,
|
||
showScaleGrid: false,
|
||
cameraProjection: 'perspective',
|
||
cameraOrbit: 'spherical',
|
||
}
|
||
}
|
||
|
||
private _camControlsCameraChange = () => {}
|
||
set camControlsCameraChange(cb: () => void) {
|
||
this._camControlsCameraChange = cb
|
||
}
|
||
|
||
private onEngineConnectionOpened = () => {}
|
||
private onEngineConnectionClosed = () => {}
|
||
private onVideoTrackMute = () => {}
|
||
private onDarkThemeMediaQueryChange = (e: MediaQueryListEvent) => {
|
||
this.setTheme(e.matches ? Themes.Dark : Themes.Light).catch(reportRejection)
|
||
}
|
||
private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {}
|
||
private onEngineConnectionNewTrack = ({
|
||
detail,
|
||
}: CustomEvent<NewTrackArgs>) => {}
|
||
modelingSend: ReturnType<typeof useModelingContext>['send'] =
|
||
(() => {}) as any
|
||
kclManager: null | KclManager = null
|
||
codeManager?: CodeManager
|
||
rustContext?: RustContext
|
||
sceneInfra?: SceneInfra
|
||
|
||
// The current "manufacturing machine" aka 3D printer, CNC, etc.
|
||
public machineManager: MachineManager | null = null
|
||
|
||
// Dispatch to the application the engine needs a restart.
|
||
private onEngineConnectionRestartRequest = () => {
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineCommandManagerEvents.EngineRestartRequest, {})
|
||
)
|
||
}
|
||
|
||
private onOffline = () => {
|
||
console.log('Browser reported network is offline')
|
||
if (TEST) {
|
||
console.warn('DURING TESTS ENGINECONNECTION.ONOFFLINE WILL DO NOTHING.')
|
||
return
|
||
}
|
||
this.onEngineConnectionRestartRequest()
|
||
}
|
||
|
||
idleMode: boolean = false
|
||
|
||
start({
|
||
setMediaStream,
|
||
setIsStreamReady,
|
||
width,
|
||
height,
|
||
token,
|
||
settings = {
|
||
pool: null,
|
||
theme: Themes.Dark,
|
||
highlightEdges: true,
|
||
enableSSAO: true,
|
||
showScaleGrid: false,
|
||
cameraProjection: 'orthographic',
|
||
cameraOrbit: 'spherical',
|
||
},
|
||
// When passed, use a completely separate connecting code path that simply
|
||
// opens a websocket and this is a function that is called when connected.
|
||
callbackOnEngineLiteConnect,
|
||
}: {
|
||
callbackOnEngineLiteConnect?: () => void
|
||
setMediaStream: (stream: MediaStream) => void
|
||
setIsStreamReady: (isStreamReady: boolean) => void
|
||
width: number
|
||
height: number
|
||
token?: string
|
||
settings?: SettingsViaQueryString
|
||
}) {
|
||
if (settings) {
|
||
this.settings = settings
|
||
}
|
||
if (width === 0 || height === 0) {
|
||
return
|
||
}
|
||
|
||
this.streamDimensions = {
|
||
width,
|
||
height,
|
||
}
|
||
|
||
// If we already have an engine connection, just need to resize the stream.
|
||
if (this.engineConnection) {
|
||
this.handleResize(this.streamDimensions)
|
||
return
|
||
}
|
||
|
||
window.addEventListener('offline', this.onOffline)
|
||
|
||
let additionalSettings = this.settings.enableSSAO ? '&post_effect=ssao' : ''
|
||
additionalSettings +=
|
||
'&show_grid=' + (this.settings.showScaleGrid ? 'true' : 'false')
|
||
const pool = !this.settings.pool ? '' : `&pool=${this.settings.pool}`
|
||
const url = `${VITE_KC_API_WS_MODELING_URL}?video_res_width=${width}&video_res_height=${height}${additionalSettings}${pool}`
|
||
this.engineConnection = new EngineConnection({
|
||
engineCommandManager: this,
|
||
url,
|
||
token,
|
||
callbackOnEngineLiteConnect,
|
||
})
|
||
|
||
// Nothing more to do when using a lite engine initialization
|
||
if (callbackOnEngineLiteConnect) return
|
||
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineCommandManagerEvents.EngineAvailable, {
|
||
detail: this.engineConnection,
|
||
})
|
||
)
|
||
|
||
this.engineConnection.addEventListener(
|
||
EngineConnectionEvents.RestartRequest,
|
||
this.onEngineConnectionRestartRequest as EventListener
|
||
)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||
this.onEngineConnectionOpened = async () => {
|
||
console.log('onEngineConnectionOpened')
|
||
|
||
try {
|
||
console.log('clearing scene and busting cache')
|
||
await this.rustContext?.clearSceneAndBustCache(
|
||
await jsAppSettings(),
|
||
this.codeManager?.currentFilePath || undefined
|
||
)
|
||
} catch (e) {
|
||
// If this happens shit's actually gone south aka the websocket closed.
|
||
// Let's restart.
|
||
console.warn("shit's gone south")
|
||
console.warn(e)
|
||
this.engineConnection?.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
|
||
)
|
||
return
|
||
}
|
||
|
||
// Set the stream's camera projection type
|
||
// We don't send a command to the engine if in perspective mode because
|
||
// for now it's the engine's default.
|
||
if (settings.cameraProjection === 'orthographic') {
|
||
console.log('Setting camera to orthographic')
|
||
await this.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_set_orthographic',
|
||
},
|
||
})
|
||
}
|
||
|
||
// Set the theme
|
||
console.log('Setting theme', this.settings.theme)
|
||
await this.setTheme(this.settings.theme)
|
||
// Set up a listener for the dark theme media query
|
||
console.log('Setup theme media query change')
|
||
darkModeMatcher?.addEventListener(
|
||
'change',
|
||
this.onDarkThemeMediaQueryChange
|
||
)
|
||
|
||
// Set the edge lines visibility
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
console.log('setting edge_lines_visible')
|
||
await this.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'edge_lines_visible' as any, // TODO: update kittycad.ts to use the correct type
|
||
hidden: !this.settings.highlightEdges,
|
||
},
|
||
})
|
||
|
||
console.log('camControlsCameraChange')
|
||
this._camControlsCameraChange()
|
||
|
||
// We should eventually only have 1 restoral call.
|
||
if (this.idleMode) {
|
||
await this.sceneInfra?.camControls.restoreRemoteCameraStateAndTriggerSync()
|
||
} else {
|
||
// NOTE: This code is old. It uses the old hack to restore camera.
|
||
console.log('call default_camera_get_settings')
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
await this.sendSceneCommand({
|
||
// CameraControls subscribes to default_camera_get_settings response events
|
||
// firing this at connection ensure the camera's are synced initially
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_get_settings',
|
||
},
|
||
})
|
||
}
|
||
|
||
setIsStreamReady(true)
|
||
|
||
console.log('Dispatching SceneReady')
|
||
// Other parts of the application should use this to react on scene ready.
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineCommandManagerEvents.SceneReady, {
|
||
detail: this.engineConnection,
|
||
})
|
||
)
|
||
}
|
||
|
||
this.engineConnection.addEventListener(
|
||
EngineConnectionEvents.Opened,
|
||
this.onEngineConnectionOpened
|
||
)
|
||
|
||
this.onEngineConnectionClosed = () => {
|
||
setIsStreamReady(false)
|
||
}
|
||
this.engineConnection.addEventListener(
|
||
EngineConnectionEvents.Closed,
|
||
this.onEngineConnectionClosed
|
||
)
|
||
|
||
this.onEngineConnectionStarted = ({ detail: engineConnection }: any) => {
|
||
engineConnection?.pc?.addEventListener(
|
||
'datachannel',
|
||
(event: RTCDataChannelEvent) => {
|
||
let unreliableDataChannel = event.channel
|
||
|
||
unreliableDataChannel.addEventListener(
|
||
'message',
|
||
(event: MessageEvent) => {
|
||
const result: UnreliableResponses = JSON.parse(event.data)
|
||
Object.values(
|
||
this.unreliableSubscriptions[result.type] || {}
|
||
).forEach(
|
||
// TODO: There is only one response that uses the unreliable channel atm,
|
||
// highlight_set_entity, if there are more it's likely they will all have the same
|
||
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
|
||
// per unreliable subscription.
|
||
(callback) => {
|
||
let data = result?.data
|
||
if (isHighlightSetEntity_type(data)) {
|
||
if (
|
||
data.sequence !== undefined &&
|
||
data.sequence > this.inSequence
|
||
) {
|
||
this.inSequence = data.sequence
|
||
callback(result)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
)
|
||
}
|
||
)
|
||
|
||
// When the EngineConnection starts a connection, we want to register
|
||
// callbacks into the WebSocket/PeerConnection.
|
||
engineConnection.websocket?.addEventListener('message', ((
|
||
event: MessageEvent
|
||
) => {
|
||
this.handleMessage(event)
|
||
}) as EventListener)
|
||
|
||
this.onVideoTrackMute = () => {
|
||
console.warn('video track mute - potentially lost stream for a moment')
|
||
}
|
||
|
||
this.onEngineConnectionNewTrack = ({
|
||
detail: { mediaStream },
|
||
}: CustomEvent<NewTrackArgs>) => {
|
||
// Engine side had an oopsie (client sent trickle_ice, engine no happy)
|
||
mediaStream
|
||
.getVideoTracks()[0]
|
||
.addEventListener('mute', this.onVideoTrackMute)
|
||
setMediaStream(mediaStream)
|
||
}
|
||
this.engineConnection?.addEventListener(
|
||
EngineConnectionEvents.NewTrack,
|
||
this.onEngineConnectionNewTrack as EventListener
|
||
)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.engineConnection?.connect({ reconnect: false })
|
||
}
|
||
this.engineConnection.addEventListener(
|
||
EngineConnectionEvents.ConnectionStarted,
|
||
this.onEngineConnectionStarted
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
async startFromWasm(token: string): Promise<boolean> {
|
||
return await new Promise((resolve) => {
|
||
this.start({
|
||
token,
|
||
width: 256,
|
||
height: 256,
|
||
setMediaStream: () => {},
|
||
setIsStreamReady: () => {},
|
||
callbackOnEngineLiteConnect: () => {
|
||
resolve(true)
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
handleMessage(event: MessageEvent) {
|
||
let message: Models['WebSocketResponse_type'] | null = null
|
||
|
||
if (event.data instanceof ArrayBuffer) {
|
||
// BSON deserialize the command.
|
||
message = BSON.deserialize(
|
||
new Uint8Array(event.data)
|
||
) as Models['WebSocketResponse_type']
|
||
// The request id comes back as binary and we want to get the uuid
|
||
// string from that.
|
||
if (message.request_id) {
|
||
message.request_id = binaryToUuid(message.request_id)
|
||
}
|
||
} else {
|
||
message = JSON.parse(event.data)
|
||
}
|
||
|
||
if (message === null) {
|
||
// We should never get here.
|
||
console.error('Received a null message from the engine', event)
|
||
return
|
||
}
|
||
|
||
if (message.request_id === undefined || message.request_id === null) {
|
||
// We only care about messages that have a request id, so we can
|
||
// ignore the rest.
|
||
return
|
||
}
|
||
|
||
// In either case (success / fail) we want to send the response back over the wire to
|
||
// the rust side.
|
||
this.rustContext?.sendResponse(message).catch((err) => {
|
||
console.error('Error sending response to rust', err)
|
||
})
|
||
|
||
const pending = this.pendingCommands[message.request_id || '']
|
||
|
||
if (pending && !message.success) {
|
||
// handle bad case
|
||
pending.reject([message])
|
||
delete this.pendingCommands[message.request_id || '']
|
||
}
|
||
|
||
if (
|
||
!(
|
||
pending &&
|
||
message.success &&
|
||
(message.resp.type === 'modeling' ||
|
||
message.resp.type === 'modeling_batch' ||
|
||
message.resp.type === 'export')
|
||
)
|
||
) {
|
||
if (pending) {
|
||
pending.reject([message])
|
||
delete this.pendingCommands[message.request_id || '']
|
||
}
|
||
return
|
||
}
|
||
|
||
pending.resolve([message])
|
||
delete this.pendingCommands[message.request_id || '']
|
||
|
||
if (message.resp.type === 'export' && message.request_id) {
|
||
this.responseMap[message.request_id] = message.resp
|
||
} else if (
|
||
message.resp.type === 'modeling' &&
|
||
pending.command.type === 'modeling_cmd_req' &&
|
||
message.request_id
|
||
) {
|
||
this.addCommandLog({
|
||
type: CommandLogType.ReceiveReliable,
|
||
data: message.resp,
|
||
id: message?.request_id || '',
|
||
cmd_type: pending?.command?.cmd?.type,
|
||
})
|
||
|
||
const modelingResponse = message.resp.data.modeling_response
|
||
|
||
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
||
(callback) => callback(modelingResponse)
|
||
)
|
||
|
||
this.responseMap[message.request_id] = message.resp
|
||
} else if (
|
||
message.resp.type === 'modeling_batch' &&
|
||
pending.command.type === 'modeling_cmd_batch_req'
|
||
) {
|
||
let individualPendingResponses: {
|
||
[key: string]: Models['WebSocketRequest_type']
|
||
} = {}
|
||
pending.command.requests.forEach(({ cmd, cmd_id }) => {
|
||
individualPendingResponses[cmd_id] = {
|
||
type: 'modeling_cmd_req',
|
||
cmd,
|
||
cmd_id,
|
||
}
|
||
})
|
||
Object.entries(message.resp.data.responses).forEach(
|
||
([commandId, response]) => {
|
||
if (!('response' in response)) return
|
||
const command = individualPendingResponses[commandId]
|
||
if (!command) return
|
||
if (command.type === 'modeling_cmd_req')
|
||
this.addCommandLog({
|
||
type: CommandLogType.ReceiveReliable,
|
||
data: {
|
||
type: 'modeling',
|
||
data: {
|
||
modeling_response: response.response,
|
||
},
|
||
},
|
||
id: commandId,
|
||
cmd_type: command?.cmd?.type,
|
||
})
|
||
|
||
this.responseMap[commandId] = {
|
||
type: 'modeling',
|
||
data: {
|
||
modeling_response: response.response,
|
||
},
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
handleResize({ width, height }: { width: number; height: number }) {
|
||
if (!this.engineConnection?.isReady()) {
|
||
return
|
||
}
|
||
|
||
this.streamDimensions = {
|
||
width,
|
||
height,
|
||
}
|
||
|
||
const resizeCmd: EngineCommand = {
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'reconfigure_stream',
|
||
...this.streamDimensions,
|
||
fps: 60, // This is required but it does next to nothing
|
||
},
|
||
}
|
||
this.engineConnection?.send(resizeCmd)
|
||
}
|
||
|
||
tearDown(opts?: { idleMode: boolean }) {
|
||
this.idleMode = opts?.idleMode ?? false
|
||
|
||
window.removeEventListener('offline', this.onOffline)
|
||
|
||
if (this.engineConnection) {
|
||
for (const [cmdId, pending] of Object.entries(this.pendingCommands)) {
|
||
pending.reject([
|
||
{
|
||
success: false,
|
||
errors: [
|
||
{
|
||
error_code: 'connection_problem',
|
||
message: 'no connection to send on, tearing down',
|
||
},
|
||
],
|
||
},
|
||
])
|
||
delete this.pendingCommands[cmdId]
|
||
}
|
||
|
||
this.engineConnection?.removeEventListener?.(
|
||
EngineConnectionEvents.Opened,
|
||
this.onEngineConnectionOpened
|
||
)
|
||
this.engineConnection.removeEventListener?.(
|
||
EngineConnectionEvents.Closed,
|
||
this.onEngineConnectionClosed
|
||
)
|
||
this.engineConnection.removeEventListener?.(
|
||
EngineConnectionEvents.ConnectionStarted,
|
||
this.onEngineConnectionStarted
|
||
)
|
||
this.engineConnection.removeEventListener?.(
|
||
EngineConnectionEvents.NewTrack,
|
||
this.onEngineConnectionNewTrack as EventListener
|
||
)
|
||
darkModeMatcher?.removeEventListener(
|
||
'change',
|
||
this.onDarkThemeMediaQueryChange
|
||
)
|
||
|
||
this.engineConnection?.tearDown()
|
||
|
||
// Our window.engineCommandManager.tearDown assignment causes this case to happen which is
|
||
// only really for tests.
|
||
// @ts-ignore
|
||
} else if (this.engineCommandManager?.engineConnection) {
|
||
// @ts-ignore
|
||
this.engineCommandManager?.engineConnection?.tearDown()
|
||
// @ts-ignore
|
||
this.engineCommandManager.engineConnection = null
|
||
}
|
||
this.engineConnection = undefined
|
||
}
|
||
async startNewSession() {
|
||
this.responseMap = {}
|
||
}
|
||
subscribeTo<T extends ModelTypes>({
|
||
event,
|
||
callback,
|
||
}: Subscription<T>): () => void {
|
||
const localUnsubscribeId = uuidv4()
|
||
if (!this.subscriptions[event]) {
|
||
this.subscriptions[event] = {}
|
||
}
|
||
this.subscriptions[event][localUnsubscribeId] = callback
|
||
|
||
return () => this.unSubscribeTo(event, localUnsubscribeId)
|
||
}
|
||
private unSubscribeTo(event: ModelTypes, id: string) {
|
||
delete this.subscriptions[event][id]
|
||
}
|
||
subscribeToUnreliable<T extends UnreliableResponses['type']>({
|
||
event,
|
||
callback,
|
||
}: UnreliableSubscription<T>): () => void {
|
||
const localUnsubscribeId = uuidv4()
|
||
if (!this.unreliableSubscriptions[event]) {
|
||
this.unreliableSubscriptions[event] = {}
|
||
}
|
||
this.unreliableSubscriptions[event][localUnsubscribeId] = callback
|
||
return () => this.unSubscribeToUnreliable(event, localUnsubscribeId)
|
||
}
|
||
private unSubscribeToUnreliable(
|
||
event: UnreliableResponses['type'],
|
||
id: string
|
||
) {
|
||
delete this.unreliableSubscriptions[event][id]
|
||
}
|
||
addCommandLog(message: CommandLog) {
|
||
if (this.commandLogs.length > 500) {
|
||
this.commandLogs.shift()
|
||
}
|
||
this.commandLogs.push(message)
|
||
|
||
this._commandLogCallBack([...this.commandLogs])
|
||
}
|
||
clearCommandLogs() {
|
||
this.commandLogs = []
|
||
this._commandLogCallBack(this.commandLogs)
|
||
}
|
||
registerCommandLogCallback(callback: (command: CommandLog[]) => void) {
|
||
this._commandLogCallBack = callback
|
||
}
|
||
async sendSceneCommand(
|
||
command: EngineCommand,
|
||
forceWebsocket = false
|
||
): Promise<
|
||
Models['WebSocketResponse_type'] | [Models['WebSocketResponse_type']] | null
|
||
> {
|
||
if (this.engineConnection === undefined) {
|
||
return Promise.resolve(null)
|
||
}
|
||
|
||
if (!this.engineConnection?.isReady()) {
|
||
return Promise.resolve(null)
|
||
}
|
||
|
||
if (
|
||
!(
|
||
command.type === 'modeling_cmd_req' &&
|
||
(command.cmd.type === 'highlight_set_entity' ||
|
||
command.cmd.type === 'mouse_move' ||
|
||
command.cmd.type === 'camera_drag_move' ||
|
||
command.cmd.type === ('default_camera_perspective_settings' as any))
|
||
)
|
||
) {
|
||
// highlight_set_entity, mouse_move and camera_drag_move are sent over the unreliable channel and are too noisy
|
||
this.addCommandLog({
|
||
type: CommandLogType.SendScene,
|
||
data: command,
|
||
})
|
||
}
|
||
|
||
if (command.type === 'modeling_cmd_batch_req') {
|
||
this.engineConnection?.send(command)
|
||
// TODO - handlePendingCommands does not handle batch commands
|
||
// return this.handlePendingCommand(command.requests[0].cmd_id, command.cmd)
|
||
return Promise.resolve(null)
|
||
}
|
||
if (command.type !== 'modeling_cmd_req') return Promise.resolve(null)
|
||
const cmd = command.cmd
|
||
if (
|
||
(cmd.type === 'camera_drag_move' ||
|
||
cmd.type === 'handle_mouse_drag_move' ||
|
||
cmd.type === 'default_camera_zoom' ||
|
||
cmd.type === ('default_camera_perspective_settings' as any)) &&
|
||
this.engineConnection?.unreliableDataChannel &&
|
||
!forceWebsocket
|
||
) {
|
||
;(cmd as any).sequence = this.outSequence
|
||
this.outSequence++
|
||
this.engineConnection?.unreliableSend(command)
|
||
return Promise.resolve(null)
|
||
} else if (
|
||
cmd.type === 'highlight_set_entity' &&
|
||
this.engineConnection?.unreliableDataChannel
|
||
) {
|
||
cmd.sequence = this.outSequence
|
||
this.outSequence++
|
||
this.engineConnection?.unreliableSend(command)
|
||
return Promise.resolve(null)
|
||
} else if (
|
||
cmd.type === 'mouse_move' &&
|
||
this.engineConnection.unreliableDataChannel
|
||
) {
|
||
cmd.sequence = this.outSequence
|
||
this.outSequence++
|
||
this.engineConnection?.unreliableSend(command)
|
||
return Promise.resolve(null)
|
||
}
|
||
if (
|
||
command.cmd.type === 'default_camera_look_at' ||
|
||
command.cmd.type === ('default_camera_perspective_settings' as any)
|
||
) {
|
||
;(cmd as any).sequence = this.outSequence++
|
||
}
|
||
// since it's not mouse drag or highlighting send over TCP and keep track of the command
|
||
return this.sendCommand(
|
||
command.cmd_id,
|
||
{
|
||
command,
|
||
idToRangeMap: {},
|
||
range: defaultSourceRange(),
|
||
},
|
||
true // isSceneCommand
|
||
)
|
||
.then(([a]) => a)
|
||
.catch((e) => {
|
||
// TODO: Previously was never caught, we are not rejecting these pendingCommands but this needs to be handled at some point.
|
||
/*noop*/
|
||
return e
|
||
})
|
||
}
|
||
/**
|
||
* A wrapper around the sendCommand where all inputs are JSON strings
|
||
*
|
||
* This one does not wait for a response.
|
||
*/
|
||
fireModelingCommandFromWasm(
|
||
id: string,
|
||
rangeStr: string,
|
||
commandStr: string,
|
||
idToRangeStr: string
|
||
): void | Error {
|
||
if (this.engineConnection === undefined)
|
||
return new Error('engineConnection is undefined')
|
||
if (
|
||
!this.engineConnection?.isReady() &&
|
||
!this.engineConnection.isUsingConnectionLite
|
||
)
|
||
return new Error('engineConnection is not ready')
|
||
if (id === undefined) return new Error('id is undefined')
|
||
if (rangeStr === undefined) return new Error('rangeStr is undefined')
|
||
if (commandStr === undefined) return new Error('commandStr is undefined')
|
||
const range: SourceRange = JSON.parse(rangeStr)
|
||
const command: EngineCommand = JSON.parse(commandStr)
|
||
const idToRangeMap: { [key: string]: SourceRange } =
|
||
JSON.parse(idToRangeStr)
|
||
|
||
// Current executeAst is stale, going to interrupt, a new executeAst will trigger
|
||
// Used in conjunction with rejectAllModelingCommands
|
||
if (this?.kclManager?.executeIsStale) {
|
||
return new Error(EXECUTE_AST_INTERRUPT_ERROR_MESSAGE)
|
||
}
|
||
|
||
// We purposely don't wait for a response here
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.sendCommand(id, {
|
||
command,
|
||
range,
|
||
idToRangeMap,
|
||
})
|
||
}
|
||
/**
|
||
* A wrapper around the sendCommand where all inputs are JSON strings
|
||
*/
|
||
async sendModelingCommandFromWasm(
|
||
id: string,
|
||
rangeStr: string,
|
||
commandStr: string,
|
||
idToRangeStr: string
|
||
): Promise<Uint8Array | void> {
|
||
if (this.engineConnection === undefined) return Promise.resolve()
|
||
if (
|
||
!this.engineConnection?.isReady() &&
|
||
!this.engineConnection.isUsingConnectionLite
|
||
)
|
||
return Promise.resolve()
|
||
if (id === undefined) return Promise.reject(new Error('id is undefined'))
|
||
if (rangeStr === undefined)
|
||
return Promise.reject(new Error('rangeStr is undefined'))
|
||
if (commandStr === undefined)
|
||
return Promise.reject(new Error('commandStr is undefined'))
|
||
const range: SourceRange = JSON.parse(rangeStr)
|
||
const command: EngineCommand = JSON.parse(commandStr)
|
||
const idToRangeMap: { [key: string]: SourceRange } =
|
||
JSON.parse(idToRangeStr)
|
||
|
||
// Current executeAst is stale, going to interrupt, a new executeAst will trigger
|
||
// Used in conjunction with rejectAllModelingCommands
|
||
if (this?.kclManager?.executeIsStale) {
|
||
return Promise.reject(EXECUTE_AST_INTERRUPT_ERROR_MESSAGE)
|
||
}
|
||
|
||
try {
|
||
const resp = await this.sendCommand(id, {
|
||
command,
|
||
range,
|
||
idToRangeMap,
|
||
})
|
||
return BSON.serialize(resp[0])
|
||
} catch (e) {
|
||
if (isArray(e) && e.length > 0) {
|
||
return Promise.reject(JSON.stringify(e[0]))
|
||
}
|
||
|
||
return Promise.reject(JSON.stringify(e))
|
||
}
|
||
}
|
||
/**
|
||
* Common send command function used for both modeling and scene commands
|
||
* So that both have a common way to send pending commands with promises for the responses
|
||
*/
|
||
async sendCommand(
|
||
id: string,
|
||
message: {
|
||
command: PendingMessage['command']
|
||
range: PendingMessage['range']
|
||
idToRangeMap: PendingMessage['idToRangeMap']
|
||
},
|
||
isSceneCommand = false
|
||
): Promise<[Models['WebSocketResponse_type']]> {
|
||
const { promise, resolve, reject } = promiseFactory<any>()
|
||
this.pendingCommands[id] = {
|
||
resolve,
|
||
reject,
|
||
promise,
|
||
command: message.command,
|
||
range: message.range,
|
||
idToRangeMap: message.idToRangeMap,
|
||
isSceneCommand,
|
||
}
|
||
|
||
this.engineConnection?.send(message.command)
|
||
return promise
|
||
}
|
||
|
||
/**
|
||
* When an execution takes place we want to wait until we've got replies for all of the commands
|
||
* When this is done when we build the artifact map synchronously.
|
||
*/
|
||
waitForAllCommands() {
|
||
return Promise.all(
|
||
Object.values(this.pendingCommands).map((a) => a.promise)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Reject all of the modeling pendingCommands created from sendModelingCommandFromWasm
|
||
* This interrupts the runtime of executeAst. Stops the AST processing and stops sending commands
|
||
* to the engine
|
||
*/
|
||
rejectAllModelingCommands(rejectionMessage: string) {
|
||
for (const [cmdId, pending] of Object.entries(this.pendingCommands)) {
|
||
if (!pending.isSceneCommand) {
|
||
pending.reject([
|
||
{
|
||
success: false,
|
||
errors: [{ error_code: 'internal_api', message: rejectionMessage }],
|
||
},
|
||
])
|
||
delete this.pendingCommands[cmdId]
|
||
}
|
||
}
|
||
}
|
||
|
||
async setPlaneHidden(id: string, hidden: boolean) {
|
||
if (this.engineConnection === undefined) return
|
||
|
||
// Can't send commands if there's no connection
|
||
if (
|
||
this.engineConnection.state.type ===
|
||
EngineConnectionStateType.Disconnecting ||
|
||
this.engineConnection.state.type ===
|
||
EngineConnectionStateType.Disconnected
|
||
)
|
||
return
|
||
|
||
return await this.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'object_visible',
|
||
object_id: id,
|
||
hidden: hidden,
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Set the engine's theme
|
||
*/
|
||
async setTheme(theme: Themes) {
|
||
// Set the stream background color
|
||
// This takes RGBA values from 0-1
|
||
// So we convert from the conventional 0-255 found in Figma
|
||
await this.sendSceneCommand({
|
||
cmd_id: uuidv4(),
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'set_background_color',
|
||
color: getThemeColorForEngine(theme),
|
||
},
|
||
})
|
||
|
||
// Sets the default line colors
|
||
const opposingTheme = getOppositeTheme(theme)
|
||
await this.sendSceneCommand({
|
||
cmd_id: uuidv4(),
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'set_default_system_properties',
|
||
color: getThemeColorForEngine(opposingTheme),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
function promiseFactory<T>() {
|
||
let resolve: (value: T | PromiseLike<T>) => void = () => {}
|
||
let reject: (value: T | PromiseLike<T>) => void = () => {}
|
||
const promise = new Promise<T>((_resolve, _reject) => {
|
||
resolve = _resolve
|
||
reject = _reject
|
||
})
|
||
return { promise, resolve, reject }
|
||
}
|