diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index cca8bff32..d7434b4c3 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' import { + EngineConnectionStateType, + DisconnectingType, EngineCommandManagerEvents, EngineConnectionEvents, ConnectionError, @@ -13,8 +15,12 @@ const Loading = ({ children }: React.PropsWithChildren) => { const [error, setError] = useState(ConnectionError.Unset) useEffect(() => { - const onConnectionStateChange = (state: EngineConnectionState) => { - console.log("", state) + const onConnectionStateChange = ({ detail: state }: CustomEvent) => { + if ( + (state.type !== EngineConnectionStateType.Disconnected + || state.type !== EngineConnectionStateType.Disconnecting) + && state.value?.type !== DisconnectingType.Error) return + setError(state.value.value.error) } const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => { diff --git a/src/hooks/useNetworkStatus.tsx b/src/hooks/useNetworkStatus.tsx index 43ef2617b..8264bd512 100644 --- a/src/hooks/useNetworkStatus.tsx +++ b/src/hooks/useNetworkStatus.tsx @@ -56,11 +56,11 @@ export function useNetworkStatus() { useEffect(() => { const onlineCallback = () => { - setSteps(initialConnectingTypeGroupState) setInternetConnected(true) } const offlineCallback = () => { setInternetConnected(false) + setSteps(structuredClone(initialConnectingTypeGroupState)) } window.addEventListener('online', onlineCallback) window.addEventListener('offline', offlineCallback) @@ -70,10 +70,6 @@ export function useNetworkStatus() { } }, []) - useEffect(() => { - console.log("pingPongHealth", pingPongHealth) - }, [pingPongHealth]) - useEffect(() => { const issues = { [ConnectingTypeGroup.WebSocket]: steps[ diff --git a/src/hooks/useSetupEngineManager.ts b/src/hooks/useSetupEngineManager.ts index 42de9112e..28f51df9a 100644 --- a/src/hooks/useSetupEngineManager.ts +++ b/src/hooks/useSetupEngineManager.ts @@ -43,7 +43,7 @@ export function useSetupEngineManager( engineCommandManager.pool = settings.pool } - useLayoutEffect(() => { + const startEngineInstance = () => { // Load the engine command manager once with the initial width and height, // then we do not want to reload it. const { width: quadWidth, height: quadHeight } = getDimensions( @@ -73,7 +73,9 @@ export function useSetupEngineManager( }) hasSetNonZeroDimensions.current = true } - }, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight]) + } + + useLayoutEffect(startEngineInstance, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight]) useEffect(() => { const handleResize = deferExecution(() => { @@ -96,8 +98,20 @@ export function useSetupEngineManager( } }, 500) + const onOnline = () => { + startEngineInstance() + } + + const onOffline = () => { + engineCommandManager.tearDown() + } + + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) window.addEventListener('resize', handleResize) return () => { + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) window.removeEventListener('resize', handleResize) } }, []) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 30941bda2..aed239b4f 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -1,8 +1,11 @@ import { PathToNode, Program, SourceRange } from 'lang/wasm' -import { VITE_KC_API_WS_MODELING_URL } from 'env' +import { invoke } from '@tauri-apps/api/core' +import { VITE_KC_API_WS_MODELING_URL, VITE_KC_API_BASE_URL } from 'env' import { Models } from '@kittycad/lib' import { exportSave } from 'lib/exportSave' import { uuidv4 } from 'lib/utils' +import withBaseURL from 'lib/withBaseURL' +import { isTauri } from 'lib/isTauri' import { getNodePathFromSourceRange } from 'lang/queryAst' import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' @@ -140,7 +143,7 @@ export const CONNECTION_ERROR_TEXT: Record = { [ConnectionError.DataChannelError]: "The data channel signaled an error.", [ConnectionError.WebSocketError]: "The websocket signaled an error.", [ConnectionError.LocalDescriptionInvalid]: "The local description is invalid.", - [ConnectionError.BadAuthToken]: "Your authorization token is not valid; 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.", } @@ -150,7 +153,7 @@ export interface ErrorType { error: ConnectionError, // Additional context. - context?: string, + context?: any, // We assign this in the state setter because we may have not failed at // a Connecting state, which we check for there. @@ -276,10 +279,11 @@ class EngineConnection extends EventTarget { if (next.type === EngineConnectionStateType.Disconnecting) { const sub = next.value if (sub.type === DisconnectingType.Error) { + console.log(sub) + // Record the last step we failed at. // (Check the current state that we're about to override that // it was a Connecting state.) - console.log(sub) if (this._state.type === EngineConnectionStateType.Connecting) { if (!sub.value) sub.value = {} sub.value.lastConnectingValue = this._state.value @@ -366,13 +370,18 @@ class EngineConnection extends EventTarget { return } - // Information on the connect transaction - 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' @@ -440,7 +449,7 @@ class EngineConnection extends EventTarget { type: DisconnectingType.Error, value: { error: ConnectionError.ICENegotiate, - context: event.toString(), + context: event, }, }, } @@ -566,7 +575,6 @@ class EngineConnection extends EventTarget { }) this.unreliableDataChannel.addEventListener('close', (event) => { - console.log("data channel close") this.disconnectAll() this.finalizeIfAllConnectionsClosed() @@ -581,7 +589,7 @@ class EngineConnection extends EventTarget { type: DisconnectingType.Error, value: { error: ConnectionError.DataChannelError, - context: event.toString(), + context: event, }, }, } @@ -619,278 +627,310 @@ class EngineConnection extends EventTarget { }, } - this.websocket = new WebSocket(this.url, []) - this.websocket.binaryType = 'arraybuffer' + const createWebSocketConnection = () => { + this.websocket = new WebSocket(this.url, []) + this.websocket.binaryType = 'arraybuffer' - this.websocket.addEventListener('open', (event) => { - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.WebSocketOpen, - }, - } - - // Send an initial ping - this.send({ type: 'ping' }) - this.pingPongSpan.ping = new Date() - - // This is required for when KCMA is running stand-alone / within Tauri. - // 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}` }, - }) - } - }) - - this.websocket.addEventListener('close', (event) => { - console.log("websocket close") - - this.disconnectAll() - this.finalizeIfAllConnectionsClosed() - }) - - this.websocket.addEventListener('error', (event) => { - this.disconnectAll() - - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + this.websocket.addEventListener('open', (event) => { + this.state = { + type: EngineConnectionStateType.Connecting, value: { - error: ConnectionError.WebSocketError, - context: event.toString(), + type: ConnectingType.WebSocketOpen, }, - }, - } - }) - - this.websocket.addEventListener('message', (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. - - console.log("websocket message", event) - - 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.artifactMap[message.request_id] || - this.engineCommandManager.lastArtifactMap[message.request_id] - console.error( - `Error in response to request ${message.request_id}:\n${errorsString} -failed cmd type was ${artifactThatFailed?.commandType}` - ) - } else { - console.error(`Error from server:\n${errorsString}`) } - return - } - let resp = message.resp + // Send an initial ping + this.send({ type: 'ping' }) + this.pingPongSpan.ping = new Date() - // If there's no body to the response, we can bail here. - if (!resp || !resp.type) { - return - } + // This is required for when KCMA is running stand-alone / within Tauri. + // Otherwise when run in a browser, the token is sent implicitly via + // the Cookie header. + if (this.token) { + this.send({ headers: { Authorization: `Bearer ${this.token}` } }) + } + }) - switch (resp.type) { - case 'pong': - this.pingPongSpan.pong = new Date() - 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', - }) - ) - } - } - break - case 'ice_server_info': - let ice_servers = resp.data?.ice_servers + this.websocket.addEventListener('close', (event) => { + this.disconnectAll() + this.finalizeIfAllConnectionsClosed() + }) - // Now that we have some ICE servers it makes sense - // to start initializing the RTCPeerConnection. RTCPeerConnection - // will begin the ICE process. - createPeerConnection() + this.websocket.addEventListener('error', (event) => { + this.disconnectAll() - this.state = { - type: EngineConnectionStateType.Connecting, + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Error, value: { - type: ConnectingType.PeerConnectionCreated, + error: ConnectionError.WebSocketError, + context: event, }, - } + }, + } + }) - // 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', + this.websocket.addEventListener('message', (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.artifactMap[message.request_id] || + this.engineCommandManager.lastArtifactMap[message.request_id] + console.error( + `Error in response to request ${message.request_id}:\n${errorsString} + failed cmd type was ${artifactThatFailed?.commandType}` + ) + console.log(artifactThatFailed) } 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', - }) + console.error(`Error from server:\n${errorsString}`) } + return + } - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.ICEServersSet, - }, - } + let resp = message.resp - // 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. + // If there's no body to the response, we can bail here. + if (!resp || !resp.type) { + return + } - // 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) => { - console.log(offer) - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.SetLocalDescription, - }, + switch (resp.type) { + case 'pong': + this.pingPongSpan.pong = new Date() + 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', + }) + ) } - return this.pc?.setLocalDescription(offer).then(() => { - this.send({ - type: 'sdp_offer', - offer: { - sdp: offer.sdp || '', - type: offer.type, - }, - }) + } + break + 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) => { + console.log(offer) this.state = { type: EngineConnectionStateType.Connecting, value: { - type: ConnectingType.OfferedSdp, + type: ConnectingType.SetLocalDescription, + }, + } + return this.pc?.setLocalDescription(offer).then(() => { + this.send({ + type: 'sdp_offer', + offer, + }) + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.OfferedSdp, + }, + } + }) + }) + .catch((err: Error) => { + // The local description is invalid, so there's no point continuing. + this.disconnectAll() + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Error, + value: { + error: ConnectionError.LocalDescriptionInvalid, + context: err, + }, }, } }) + break + + case 'sdp_answer': + let answer = resp.data?.answer + if (!answer || answer.type === 'unspecified') { + return + } + + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.ReceivedSdp, + }, + } + + // As soon as this is set, RTCPeerConnection tries to + // establish a connection. + // @ts-ignore + // Have to ignore because dom.ts doesn't have the right type + void this.pc?.setRemoteDescription(answer) + + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.SetRemoteDescription, + }, + } + + this.state = { + type: EngineConnectionStateType.Connecting, + value: { + type: ConnectingType.WebRTCConnecting, + }, + } + break + + case 'trickle_ice': + let candidate = resp.data?.candidate + console.log('trickle_ice: using this candidate: ', 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, + }) }) - .catch((err: Error) => { - // The local description is invalid, so there's no point continuing. - this.disconnectAll() - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, - value: { - error: ConnectionError.LocalDescriptionInvalid, - context: err.toString(), - }, - }, - } - }) - break + break + } + }) + } - case 'sdp_answer': - let answer = resp.data?.answer - if (!answer || answer.type === 'unspecified') { - return - } - - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.ReceivedSdp, - }, - } - - // As soon as this is set, RTCPeerConnection tries to - // establish a connection. - // @ts-ignore - // Have to ignore because dom.ts doesn't have the right type - void this.pc?.setRemoteDescription(answer) - - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.SetRemoteDescription, - }, - } - - this.state = { - type: EngineConnectionStateType.Connecting, - value: { - type: ConnectingType.WebRTCConnecting, - }, - } - break - - case 'trickle_ice': - let candidate = resp.data?.candidate - console.log('trickle_ice: using this candidate: ', 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, - }) + // api-deux currently doesn't report if an auth token is invalid on the + // websocket. As a workaround we can invoke two endpoints: /user and + // /user/session/{token} . Former for regular operations, latter for + // development. + // Resolver: https://github.com/KittyCAD/api-deux/issues/1628 + const promiseIsAuthed = this.token + // We can't check tokens in localStorage, at least not yet. + // Resolver: https://github.com/KittyCAD/api-deux/issues/1629 + ? Promise.resolve({ status: 200 }) + : !isTauri() + ? fetch(withBaseURL('/user')) + : invoke>('get_user', { + token: this.token, + hostname: VITE_KC_API_BASE_URL, }) - break + + promiseIsAuthed + .then((e) => { + if (e.status >= 200 && e.status < 400) { + createWebSocketConnection() + } else if (e.status === 401) { + this.state = { + type: EngineConnectionStateType.Disconnected, + value: { + type: DisconnectingType.Error, + value: { + error: ConnectionError.BadAuthToken, + context: e, + }, + }, + } + } else { + this.state = { + type: EngineConnectionStateType.Disconnected, + value: { + type: DisconnectingType.Error, + value: { + error: ConnectionError.Unknown, + context: e, + }, + }, + } } }) - - this.dispatchEvent( - new CustomEvent(EngineConnectionEvents.ConnectionStarted, { - detail: this, - }) - ) } // Do not change this back to an object or any, we should only be sending the // WebSocketRequest type!