* Revert "Correct hovering highlights on HiDPI screens + correct 2 flakey tests…"
This reverts commit 5743b9ced0
.
* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2314 lines
76 KiB
TypeScript
2314 lines
76 KiB
TypeScript
import {
|
||
ArtifactGraph,
|
||
defaultSourceRange,
|
||
ExecState,
|
||
SourceRange,
|
||
} from 'lang/wasm'
|
||
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from 'env'
|
||
import { Models } from '@kittycad/lib'
|
||
import { exportSave } from 'lib/exportSave'
|
||
import { deferExecution, isOverlap, uuidv4 } from 'lib/utils'
|
||
import {
|
||
Themes,
|
||
getThemeColorForEngine,
|
||
getOppositeTheme,
|
||
darkModeMatcher,
|
||
} from 'lib/theme'
|
||
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
|
||
import { EngineCommand, ResponseMap } from 'lang/std/artifactGraph'
|
||
import { useModelingContext } from 'hooks/useModelingContext'
|
||
import { exportMake } from 'lib/exportMake'
|
||
import toast from 'react-hot-toast'
|
||
import { SettingsViaQueryString } from 'lib/settings/settingsTypes'
|
||
import {
|
||
EXECUTE_AST_INTERRUPT_ERROR_MESSAGE,
|
||
EXPORT_TOAST_MESSAGES,
|
||
MAKE_TOAST_MESSAGES,
|
||
} from 'lib/constants'
|
||
import { KclManager } from 'lang/KclSingleton'
|
||
import { err, reportRejection } from 'lib/trap'
|
||
import { markOnce } from 'lib/performance'
|
||
import { MachineManager } from 'components/MachineManagerProvider'
|
||
import { DefaultPlaneStr } from 'lib/planes'
|
||
import { defaultPlaneStrToKey } from 'lib/planes'
|
||
import { buildArtifactIndex } from 'lib/artifactIndex'
|
||
import { ArtifactIndex } from 'lib/artifactIndex'
|
||
|
||
// TODO(paultag): This ought to be tweakable.
|
||
const pingIntervalMs = 5_000
|
||
|
||
function isHighlightSetEntity_type(
|
||
data: any
|
||
): data is Models['HighlightSetEntity_type'] {
|
||
return data.entity_id && data.sequence
|
||
}
|
||
|
||
type OkWebSocketResponseData = Models['OkWebSocketResponseData_type']
|
||
|
||
interface NewTrackArgs {
|
||
conn: EngineConnection
|
||
mediaStream: MediaStream
|
||
}
|
||
|
||
export enum ExportIntent {
|
||
Save = 'save',
|
||
Make = 'make',
|
||
}
|
||
|
||
export interface ExportInfo {
|
||
intent: ExportIntent
|
||
name: string
|
||
}
|
||
|
||
type ClientMetrics = Models['ClientMetrics_type']
|
||
|
||
interface WebRTCClientMetrics extends ClientMetrics {
|
||
rtc_frame_height: number
|
||
rtc_frame_width: number
|
||
rtc_packets_lost: number
|
||
rtc_pli_count: number
|
||
rtc_pause_count: number
|
||
rtc_total_pauses_duration_sec: number
|
||
}
|
||
|
||
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,
|
||
|
||
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,
|
||
|
||
// 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.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 connections.',
|
||
[ConnectionError.Unknown]:
|
||
'An unexpected error occurred. Please report this to us.',
|
||
}
|
||
|
||
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 type PingPongState = 'OK' | 'TIMEOUT'
|
||
|
||
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
|
||
|
||
// 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
|
||
idleMode: boolean = false
|
||
promise?: Promise<void>
|
||
sdpAnswer?: RTCSessionDescriptionInit
|
||
triggeredStart = false
|
||
|
||
onIceCandidate = function (
|
||
this: RTCPeerConnection,
|
||
event: RTCPeerConnectionIceEvent
|
||
) {}
|
||
onIceCandidateError = function (
|
||
this: RTCPeerConnection,
|
||
event: RTCPeerConnectionIceErrorEvent
|
||
) {}
|
||
onConnectionStateChange = function (this: RTCPeerConnection, event: Event) {}
|
||
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
|
||
) {}
|
||
onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {}
|
||
onWebSocketOpen = function (event: Event) {}
|
||
onWebSocketClose = function (event: Event) {}
|
||
onWebSocketError = function (event: Event) {}
|
||
onWebSocketMessage = function (event: MessageEvent) {}
|
||
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<WebRTCClientMetrics>
|
||
private engineCommandManager: EngineCommandManager
|
||
|
||
private pingPongSpan: { ping?: Date; pong?: Date }
|
||
private pingIntervalId: ReturnType<typeof setInterval> = setInterval(() => {},
|
||
60_000)
|
||
isUsingConnectionLite: boolean = false
|
||
|
||
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
|
||
}
|
||
|
||
// Without an interval ping, our connection will timeout.
|
||
// If this.idleMode is true we skip this logic so only reconnect
|
||
// happens on mouse move
|
||
this.pingIntervalId = setInterval(() => {
|
||
if (this.idleMode) return
|
||
|
||
switch (this.state.type as EngineConnectionStateType) {
|
||
case EngineConnectionStateType.ConnectionEstablished:
|
||
// If there was no reply to the last ping, report a timeout and
|
||
// teardown the connection.
|
||
if (this.pingPongSpan.ping && !this.pingPongSpan.pong) {
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
||
detail: 'TIMEOUT',
|
||
})
|
||
)
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Timeout,
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
|
||
// Otherwise check the time between was >= pingIntervalMs,
|
||
// and if it was, then it's bad network health.
|
||
} else if (this.pingPongSpan.ping && this.pingPongSpan.pong) {
|
||
if (
|
||
Math.abs(
|
||
this.pingPongSpan.pong.valueOf() -
|
||
this.pingPongSpan.ping.valueOf()
|
||
) >= pingIntervalMs
|
||
) {
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
||
detail: 'TIMEOUT',
|
||
})
|
||
)
|
||
} else {
|
||
this.dispatchEvent(
|
||
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
|
||
detail: 'OK',
|
||
})
|
||
)
|
||
}
|
||
}
|
||
|
||
this.send({ type: 'ping' })
|
||
this.pingPongSpan.ping = new Date()
|
||
this.pingPongSpan.pong = undefined
|
||
break
|
||
case EngineConnectionStateType.Disconnecting:
|
||
case EngineConnectionStateType.Disconnected:
|
||
// We will do reconnection elsewhere, because we basically need
|
||
// to destroy this EngineConnection, and this setInterval loop
|
||
// lives inside it. (lee) I might change this in the future so it's
|
||
// outside this class.
|
||
break
|
||
default:
|
||
if (this.isConnecting()) break
|
||
// Means we never could do an initial connection. Reconnect everything.
|
||
if (!this.pingPongSpan.ping) this.connect().catch(reportRejection)
|
||
break
|
||
}
|
||
}, pingIntervalMs)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.connect()
|
||
}
|
||
|
||
// 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)
|
||
const pending =
|
||
this.engineCommandManager.pendingCommands[message.request_id || '']
|
||
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
|
||
}
|
||
|
||
if (
|
||
!(
|
||
pending &&
|
||
message.success &&
|
||
(message.resp.type === 'modeling' ||
|
||
message.resp.type === 'modeling_batch')
|
||
)
|
||
)
|
||
return
|
||
|
||
if (
|
||
message.resp.type === 'modeling' &&
|
||
pending.command.type === 'modeling_cmd_req' &&
|
||
message.request_id
|
||
) {
|
||
this.engineCommandManager.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.engineCommandManager.responseMap[commandId] = {
|
||
type: 'modeling',
|
||
data: {
|
||
modeling_response: response.response,
|
||
},
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
pending.resolve([message])
|
||
delete this.engineCommandManager.pendingCommands[message.request_id || '']
|
||
}) as EventListener)
|
||
}
|
||
|
||
isConnecting() {
|
||
return this.state.type === EngineConnectionStateType.Connecting
|
||
}
|
||
|
||
isReady() {
|
||
return this.state.type === EngineConnectionStateType.ConnectionEstablished
|
||
}
|
||
|
||
tearDown(opts?: { idleMode: boolean }) {
|
||
this.idleMode = opts?.idleMode ?? false
|
||
clearInterval(this.pingIntervalId)
|
||
|
||
if (opts?.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,
|
||
},
|
||
}
|
||
|
||
this.disconnectAll()
|
||
}
|
||
|
||
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(reconnecting?: boolean): Promise<void> {
|
||
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.
|
||
setTimeout(() => {
|
||
if (that.initiateConnectionExclusive()) {
|
||
console.warn('connected after 3 second delay')
|
||
}
|
||
}, 3000)
|
||
}
|
||
this.pc?.addEventListener?.('icecandidate', this.onIceCandidate)
|
||
this.pc?.addEventListener?.(
|
||
'icegatheringstatechange',
|
||
function (_event) {
|
||
console.log('icegatheringstatechange', this.iceGatheringState)
|
||
|
||
if (this.iceGatheringState !== 'complete') return
|
||
that.initiateConnectionExclusive()
|
||
}
|
||
)
|
||
|
||
this.pc?.addEventListener?.(
|
||
'iceconnectionstatechange',
|
||
function (_event) {
|
||
console.log('iceconnectionstatechange', this.iceConnectionState)
|
||
console.log('iceconnectionstatechange', this.iceGatheringState)
|
||
}
|
||
)
|
||
this.pc?.addEventListener?.('negotiationneeded', function (_event) {
|
||
console.log('negotiationneeded', this.iceConnectionState)
|
||
console.log('negotiationneeded', this.iceGatheringState)
|
||
})
|
||
this.pc?.addEventListener?.('signalingstatechange', function (event) {
|
||
console.log('signalingstatechange', this.signalingState)
|
||
})
|
||
|
||
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 'disconnected':
|
||
case 'failed':
|
||
this.pc?.removeEventListener('icecandidate', this.onIceCandidate)
|
||
this.pc?.removeEventListener(
|
||
'icecandidateerror',
|
||
this.onIceCandidateError
|
||
)
|
||
this.pc?.removeEventListener(
|
||
'connectionstatechange',
|
||
this.onConnectionStateChange
|
||
)
|
||
this.pc?.removeEventListener('track', this.onTrack)
|
||
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.ICENegotiate,
|
||
context: event,
|
||
},
|
||
},
|
||
}
|
||
this.disconnectAll()
|
||
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<WebRTCClientMetrics> => {
|
||
return new Promise((resolve, reject) => {
|
||
if (mediaStream.getVideoTracks().length !== 1) {
|
||
reject(new Error('too many video tracks to report'))
|
||
return
|
||
}
|
||
|
||
let videoTrack = mediaStream.getVideoTracks()[0]
|
||
void this.pc?.getStats(videoTrack).then((videoTrackStats) => {
|
||
let client_metrics: WebRTCClientMetrics = {
|
||
rtc_frames_decoded: 0,
|
||
rtc_frames_dropped: 0,
|
||
rtc_frames_received: 0,
|
||
rtc_frames_per_second: 0,
|
||
rtc_freeze_count: 0,
|
||
rtc_jitter_sec: 0.0,
|
||
rtc_keyframes_decoded: 0,
|
||
rtc_total_freezes_duration_sec: 0.0,
|
||
rtc_frame_height: 0,
|
||
rtc_frame_width: 0,
|
||
rtc_packets_lost: 0,
|
||
rtc_pli_count: 0,
|
||
rtc_pause_count: 0,
|
||
rtc_total_pauses_duration_sec: 0.0,
|
||
}
|
||
|
||
// 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, but for now, just report the one stream.
|
||
|
||
videoTrackStats.forEach((videoTrackReport) => {
|
||
if (videoTrackReport.type === 'inbound-rtp') {
|
||
client_metrics.rtc_frames_decoded =
|
||
videoTrackReport.framesDecoded || 0
|
||
client_metrics.rtc_frames_dropped =
|
||
videoTrackReport.framesDropped || 0
|
||
client_metrics.rtc_frames_received =
|
||
videoTrackReport.framesReceived || 0
|
||
client_metrics.rtc_frames_per_second =
|
||
videoTrackReport.framesPerSecond || 0
|
||
client_metrics.rtc_freeze_count =
|
||
videoTrackReport.freezeCount || 0
|
||
client_metrics.rtc_jitter_sec =
|
||
videoTrackReport.jitter || 0.0
|
||
client_metrics.rtc_keyframes_decoded =
|
||
videoTrackReport.keyFramesDecoded || 0
|
||
client_metrics.rtc_total_freezes_duration_sec =
|
||
videoTrackReport.totalFreezesDuration || 0
|
||
client_metrics.rtc_frame_height =
|
||
videoTrackReport.frameHeight || 0
|
||
client_metrics.rtc_frame_width =
|
||
videoTrackReport.frameWidth || 0
|
||
client_metrics.rtc_packets_lost =
|
||
videoTrackReport.packetsLost || 0
|
||
client_metrics.rtc_pli_count =
|
||
videoTrackReport.pliCount || 0
|
||
} else if (videoTrackReport.type === 'transport') {
|
||
// videoTrackReport.bytesReceived,
|
||
// videoTrackReport.bytesSent,
|
||
}
|
||
})
|
||
resolve(client_metrics)
|
||
})
|
||
})
|
||
}
|
||
|
||
// The app is eager to use the MediaStream; as soon as onNewTrack is
|
||
// called, the following sequence happens:
|
||
// EngineConnection.onNewTrack -> StoreState.setMediaStream ->
|
||
// Stream.tsx reacts to mediaStream change, setting a video element.
|
||
// We wait until connectionstatechange changes to "connected"
|
||
// to pass it to the rest of the application.
|
||
|
||
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,
|
||
},
|
||
}
|
||
|
||
// Everything is now connected.
|
||
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.pc?.removeEventListener('datachannel', this.onDataChannel)
|
||
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 KCMA 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 = new Date()
|
||
}
|
||
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) => {
|
||
this.state = {
|
||
type: EngineConnectionStateType.Disconnecting,
|
||
value: {
|
||
type: DisconnectingType.Error,
|
||
value: {
|
||
error: ConnectionError.WebSocketError,
|
||
context: 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}: ${error.message}`
|
||
})
|
||
.join('\n')
|
||
if (message.request_id) {
|
||
const artifactThatFailed =
|
||
this.engineCommandManager.artifactGraph.get(message.request_id)
|
||
console.error(
|
||
`Error in response to request ${message.request_id}:\n${errorsString}
|
||
failed cmd type was ${artifactThatFailed?.type}`
|
||
)
|
||
// Check if this was a pending export command.
|
||
if (
|
||
this.engineCommandManager.pendingExport?.commandId ===
|
||
message.request_id
|
||
) {
|
||
// Reject the promise with the error.
|
||
this.engineCommandManager.pendingExport.reject(errorsString)
|
||
toast.error(errorsString, {
|
||
id: this.engineCommandManager.pendingExport.toastId,
|
||
})
|
||
this.engineCommandManager.pendingExport = undefined
|
||
}
|
||
} 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()
|
||
}
|
||
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 = new Date()
|
||
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 (reconnecting) {
|
||
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']) {
|
||
// 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 === 3) 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?.readyState === 1) {
|
||
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) {
|
||
// Do not notify the rest of the program that we have cut off anything.
|
||
this.state = { type: EngineConnectionStateType.Disconnected }
|
||
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 type CommandLog =
|
||
| {
|
||
type: 'send-modeling'
|
||
data: EngineCommand
|
||
}
|
||
| {
|
||
type: 'send-scene'
|
||
data: EngineCommand
|
||
}
|
||
| {
|
||
type: 'receive-reliable'
|
||
data: OkWebSocketResponseData
|
||
id: string
|
||
cmd_type?: string
|
||
}
|
||
| {
|
||
type: 'execution-done'
|
||
data: null
|
||
}
|
||
| {
|
||
type: 'export-done'
|
||
data: null
|
||
}
|
||
|
||
export enum EngineCommandManagerEvents {
|
||
// engineConnection is available but scene setup may not have run
|
||
EngineAvailable = 'engine-available',
|
||
|
||
// the whole scene is ready (settings loaded)
|
||
SceneReady = 'scene-ready',
|
||
}
|
||
|
||
/**
|
||
* The EngineCommandManager is the main interface to the Engine for Modeling App.
|
||
*
|
||
* 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 artifactCommands and their responses are kept in {@link responseMap}
|
||
* Both of these data structures are used to process the {@link artifactGraph}.
|
||
*/
|
||
|
||
interface PendingMessage {
|
||
command: EngineCommand
|
||
range: SourceRange
|
||
idToRangeMap: { [key: string]: SourceRange }
|
||
resolve: (data: [Models['WebSocketResponse_type']]) => void
|
||
reject: (reason: string) => void
|
||
promise: Promise<[Models['WebSocketResponse_type']]>
|
||
isSceneCommand: boolean
|
||
}
|
||
export class EngineCommandManager extends EventTarget {
|
||
/**
|
||
* The artifactGraph is a client-side representation of the commands that have been sent
|
||
* see: src/lang/std/artifactGraph-README.md for a full explanation.
|
||
*/
|
||
artifactGraph: ArtifactGraph = new Map()
|
||
artifactIndex: ArtifactIndex = []
|
||
/**
|
||
* 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 artifactCommands, when processing the commands into the artifactGraph, 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
|
||
defaultPlanes: DefaultPlanes | null = null
|
||
commandLogs: CommandLog[] = []
|
||
pendingExport?: {
|
||
/** The id of the shared loading/success/error toast for export */
|
||
toastId: string
|
||
/** An on-success callback */
|
||
resolve: (a: null) => void
|
||
/** An on-error callback */
|
||
reject: (reason: string) => void
|
||
/** The engine command uuid */
|
||
commandId: string
|
||
}
|
||
settings: SettingsViaQueryString
|
||
width: number = 1337
|
||
height: number = 1337
|
||
|
||
/**
|
||
* Export intent traxcks the intent of the export. If it is null there is no
|
||
* export in progress. Otherwise it is an enum value of the intent.
|
||
* Another export cannot be started if one is already in progress.
|
||
*/
|
||
private _exportInfo: ExportInfo | null = null
|
||
_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 makeDefaultPlanes: () => Promise<DefaultPlanes> | null = () => null
|
||
|
||
private onEngineConnectionOpened = () => {}
|
||
private onEngineConnectionClosed = () => {}
|
||
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
|
||
|
||
// The current "manufacturing machine" aka 3D printer, CNC, etc.
|
||
public machineManager: MachineManager | null = null
|
||
|
||
set exportInfo(info: ExportInfo | null) {
|
||
this._exportInfo = info
|
||
}
|
||
|
||
get exportInfo() {
|
||
return this._exportInfo
|
||
}
|
||
|
||
start({
|
||
setMediaStream,
|
||
setIsStreamReady,
|
||
width,
|
||
height,
|
||
token,
|
||
makeDefaultPlanes,
|
||
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
|
||
makeDefaultPlanes: () => Promise<DefaultPlanes>
|
||
settings?: SettingsViaQueryString
|
||
}) {
|
||
if (settings) {
|
||
this.settings = settings
|
||
}
|
||
this.makeDefaultPlanes = makeDefaultPlanes
|
||
if (width === 0 || height === 0) {
|
||
return
|
||
}
|
||
|
||
this.width = width
|
||
this.height = height
|
||
|
||
// If we already have an engine connection, just need to resize the stream.
|
||
if (this.engineConnection) {
|
||
this.handleResize({
|
||
streamWidth: width,
|
||
streamHeight: height,
|
||
})
|
||
return
|
||
}
|
||
|
||
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,
|
||
})
|
||
)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||
this.onEngineConnectionOpened = async () => {
|
||
// 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') {
|
||
this.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_set_orthographic',
|
||
},
|
||
}).catch(reportRejection)
|
||
}
|
||
|
||
// Set the theme
|
||
this.setTheme(this.settings.theme).catch(reportRejection)
|
||
// Set up a listener for the dark theme media query
|
||
darkModeMatcher?.addEventListener(
|
||
'change',
|
||
this.onDarkThemeMediaQueryChange
|
||
)
|
||
|
||
// Set the edge lines visibility
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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,
|
||
},
|
||
})
|
||
|
||
this._camControlsCameraChange()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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',
|
||
},
|
||
})
|
||
await this.initPlanes()
|
||
setIsStreamReady(true)
|
||
|
||
// 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
|
||
) => {
|
||
if (event.data instanceof ArrayBuffer) {
|
||
// If the data is an ArrayBuffer, it's the result of an export command,
|
||
// because in all other cases we send JSON strings. But in the case of
|
||
// export we send a binary blob.
|
||
// Pass this to our export function.
|
||
if (this.exportInfo === null || this.pendingExport === undefined) {
|
||
toast.error(
|
||
'Export intent was not set, but export data was received'
|
||
)
|
||
console.error(
|
||
'Export intent was not set, but export data was received'
|
||
)
|
||
return
|
||
}
|
||
|
||
switch (this.exportInfo.intent) {
|
||
case ExportIntent.Save: {
|
||
exportSave({
|
||
data: event.data,
|
||
fileName: this.exportInfo.name,
|
||
toastId: this.pendingExport.toastId,
|
||
}).then(() => {
|
||
this.pendingExport?.resolve(null)
|
||
}, this.pendingExport?.reject)
|
||
break
|
||
}
|
||
case ExportIntent.Make: {
|
||
if (!this.machineManager) {
|
||
console.warn('Some how, no manufacturing machine is selected.')
|
||
break
|
||
}
|
||
|
||
exportMake(
|
||
event.data,
|
||
this.exportInfo.name,
|
||
this.pendingExport.toastId,
|
||
this.machineManager
|
||
).then((result) => {
|
||
if (result) {
|
||
this.pendingExport?.resolve(null)
|
||
} else {
|
||
this.pendingExport?.reject('Failed to make export')
|
||
}
|
||
}, this.pendingExport?.reject)
|
||
break
|
||
}
|
||
}
|
||
// Set the export intent back to null.
|
||
this.exportInfo = null
|
||
return
|
||
}
|
||
|
||
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
|
||
const pending = this.pendingCommands[message.request_id || '']
|
||
|
||
if (pending && !message.success) {
|
||
// handle bad case
|
||
pending.reject(`engine error: ${JSON.stringify(message.errors)}`)
|
||
delete this.pendingCommands[message.request_id || '']
|
||
}
|
||
if (
|
||
!(
|
||
pending &&
|
||
message.success &&
|
||
(message.resp.type === 'modeling' ||
|
||
message.resp.type === 'modeling_batch')
|
||
)
|
||
)
|
||
return
|
||
|
||
if (
|
||
message.resp.type === 'modeling' &&
|
||
pending.command.type === 'modeling_cmd_req' &&
|
||
message.request_id
|
||
) {
|
||
this.addCommandLog({
|
||
type: 'receive-reliable',
|
||
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: 'receive-reliable',
|
||
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,
|
||
},
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
pending.resolve([message])
|
||
delete this.pendingCommands[message.request_id || '']
|
||
}) as EventListener)
|
||
|
||
this.onEngineConnectionNewTrack = ({
|
||
detail: { mediaStream },
|
||
}: CustomEvent<NewTrackArgs>) => {
|
||
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
|
||
console.error(
|
||
'video track mute: check webrtc internals -> inbound rtp'
|
||
)
|
||
})
|
||
|
||
setMediaStream(mediaStream)
|
||
}
|
||
this.engineConnection?.addEventListener(
|
||
EngineConnectionEvents.NewTrack,
|
||
this.onEngineConnectionNewTrack as EventListener
|
||
)
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.engineConnection?.connect()
|
||
}
|
||
this.engineConnection.addEventListener(
|
||
EngineConnectionEvents.ConnectionStarted,
|
||
this.onEngineConnectionStarted
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
handleResize({
|
||
streamWidth,
|
||
streamHeight,
|
||
}: {
|
||
streamWidth: number
|
||
streamHeight: number
|
||
}) {
|
||
if (!this.engineConnection?.isReady()) {
|
||
return
|
||
}
|
||
|
||
this.width = streamWidth
|
||
this.height = streamHeight
|
||
|
||
const resizeCmd: EngineCommand = {
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'reconfigure_stream',
|
||
width: streamWidth,
|
||
height: streamHeight,
|
||
fps: 60,
|
||
},
|
||
}
|
||
this.engineConnection?.send(resizeCmd)
|
||
}
|
||
|
||
tearDown(opts?: { idleMode: boolean }) {
|
||
if (this.engineConnection) {
|
||
for (const pending of Object.values(this.pendingCommands)) {
|
||
pending.reject('no connection to send on')
|
||
}
|
||
|
||
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(opts)
|
||
|
||
// Our window.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(opts)
|
||
}
|
||
}
|
||
async startNewSession() {
|
||
this.responseMap = {}
|
||
await this.initPlanes()
|
||
}
|
||
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]
|
||
}
|
||
// We make this a separate function so we can call it from wasm.
|
||
clearDefaultPlanes() {
|
||
this.defaultPlanes = null
|
||
}
|
||
async wasmGetDefaultPlanes(): Promise<string> {
|
||
if (this.defaultPlanes === null) {
|
||
await this.initPlanes()
|
||
}
|
||
return JSON.stringify(this.defaultPlanes)
|
||
}
|
||
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
|
||
}
|
||
sendSceneCommand(
|
||
command: EngineCommand,
|
||
forceWebsocket = false
|
||
): Promise<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: 'send-scene',
|
||
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)
|
||
} else if (cmd.type === 'export') {
|
||
const promise = new Promise<null>((resolve, reject) => {
|
||
if (this.exportInfo === null) {
|
||
if (this.exportInfo === null) {
|
||
toast.error('Export intent was not set, but export is being sent')
|
||
console.error('Export intent was not set, but export is being sent')
|
||
return
|
||
}
|
||
}
|
||
const toastId = toast.loading(
|
||
this.exportInfo.intent === ExportIntent.Save
|
||
? EXPORT_TOAST_MESSAGES.START
|
||
: MAKE_TOAST_MESSAGES.START
|
||
)
|
||
this.pendingExport = {
|
||
toastId,
|
||
resolve: (passThrough) => {
|
||
this.addCommandLog({
|
||
type: 'export-done',
|
||
data: null,
|
||
})
|
||
resolve(passThrough)
|
||
},
|
||
reject: (reason: string) => {
|
||
this.exportInfo = null
|
||
reject(reason)
|
||
},
|
||
commandId: command.cmd_id,
|
||
}
|
||
})
|
||
this.engineConnection?.send(command)
|
||
return promise
|
||
}
|
||
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
|
||
*/
|
||
async sendModelingCommandFromWasm(
|
||
id: string,
|
||
rangeStr: string,
|
||
commandStr: string,
|
||
idToRangeStr: string
|
||
): Promise<string | 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)
|
||
}
|
||
|
||
const resp = await this.sendCommand(id, {
|
||
command,
|
||
range,
|
||
idToRangeMap,
|
||
})
|
||
return JSON.stringify(resp[0])
|
||
}
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
deferredArtifactPopulated = deferExecution((a?: null) => {
|
||
this.modelingSend({ type: 'Artifact graph populated' })
|
||
}, 200)
|
||
deferredArtifactEmptied = deferExecution((a?: null) => {
|
||
this.modelingSend({ type: 'Artifact graph emptied' })
|
||
}, 200)
|
||
|
||
/**
|
||
* 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)
|
||
)
|
||
}
|
||
updateArtifactGraph(execStateArtifactGraph: ExecState['artifactGraph']) {
|
||
this.artifactGraph = execStateArtifactGraph
|
||
this.artifactIndex = buildArtifactIndex(execStateArtifactGraph)
|
||
// TODO check if these still need to be deferred once e2e tests are working again.
|
||
if (this.artifactGraph.size) {
|
||
this.deferredArtifactEmptied(null)
|
||
} else {
|
||
this.deferredArtifactPopulated(null)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
Object.values(this.pendingCommands).forEach(
|
||
({ reject, isSceneCommand }) =>
|
||
!isSceneCommand && reject(rejectionMessage)
|
||
)
|
||
}
|
||
|
||
async initPlanes() {
|
||
if (this.planesInitialized()) return
|
||
const planes = await this.makeDefaultPlanes()
|
||
this.defaultPlanes = planes
|
||
}
|
||
planesInitialized(): boolean {
|
||
return (
|
||
!!this.defaultPlanes &&
|
||
this.defaultPlanes.xy !== '' &&
|
||
this.defaultPlanes.yz !== '' &&
|
||
this.defaultPlanes.xz !== ''
|
||
)
|
||
}
|
||
|
||
getDefaultPlaneId(name: DefaultPlaneStr): string | Error {
|
||
const key = defaultPlaneStrToKey(name)
|
||
if (!this.defaultPlanes) {
|
||
return new Error('Default planes not initialized')
|
||
} else if (err(key)) {
|
||
return key
|
||
}
|
||
return this.defaultPlanes[key]
|
||
}
|
||
|
||
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
|
||
this.sendSceneCommand({
|
||
cmd_id: uuidv4(),
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'set_background_color',
|
||
color: getThemeColorForEngine(theme),
|
||
},
|
||
}).catch(reportRejection)
|
||
|
||
// Sets the default line colors
|
||
const opposingTheme = getOppositeTheme(theme)
|
||
this.sendSceneCommand({
|
||
cmd_id: uuidv4(),
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'set_default_system_properties',
|
||
color: getThemeColorForEngine(opposingTheme),
|
||
},
|
||
}).catch(reportRejection)
|
||
}
|
||
|
||
// Some "objects" have the same source range, such as sketch_mode_start and start_path.
|
||
// So when passing a range, we need to also specify the command type
|
||
mapRangeToObjectId(
|
||
range: SourceRange,
|
||
commandTypeToTarget: string
|
||
): string | undefined {
|
||
for (const [artifactId, artifact] of this.artifactGraph) {
|
||
if (
|
||
'codeRef' in artifact &&
|
||
artifact.codeRef &&
|
||
isOverlap(range, artifact.codeRef.range)
|
||
) {
|
||
if (commandTypeToTarget === artifact.type) return artifactId
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
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 }
|
||
}
|