Files
speckle-connectors-dui/lib/bridge/sketchup.ts
T
Oğuzhan Koral fe77ede49e feat: introduce CI linting & fix various issues (#5)
* introduce CI checks

* fixx

* add caching

* fixes

* wip

* server bridge linting

* No lint errors

* fix paths on lint:prettier

* make files pretty again

* fix stylelint

* fix lock

---------

Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
2025-05-14 10:05:51 +03:00

448 lines
14 KiB
TypeScript

import { uniqueId } from 'lodash-es'
import { BaseBridge } from './base'
import type { ProgressStage } from '@speckle/objectloader'
import ObjectLoader from '@speckle/objectloader'
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import {
createVersionMutation,
markReceivedVersionMutation,
versionDetailsQuery
} from '~/lib/graphql/mutationsAndQueries'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
import { storeToRefs } from 'pinia'
import type {
SendBatchViaBrowserArgs,
CreateVersionViaBrowserArgs,
ReceiveViaBrowserArgs,
CreateVersionArgs
} from '~/lib/bridge/server'
declare let sketchup: {
exec: (data: Record<string, unknown>) => void
getBindingsMethodNames: (viewId: string) => void
}
type SendViaBrowserArgs = {
modelCardId: string
projectId: string
modelId: string
token: string
serverUrl: string
accountId: string
message: string
sendConversionResults: ConversionResult[]
sendObject: {
id: string // the root object id which should be used for creating the version
totalChildrenCount: number
batches: string[]
}
}
/**
* This class operates in different way than the others, because calls into Sketchup are one way only.
* E.g., we cannot return values from internal calls to it (e.g., const test = sketchup.rubyCall() does not work ).
* This class basically makes the sketchup bindings work in the same way as cef/webview by returning a promise
* on each method call. That promise is either resolved once sketchup sends back (via receiveResponse) a corresponding
* reply, or it's rejected after a given TIMEOUT_MS (currently 2s).
* TODO: implement the event dispatcher side as well.
*/
export class SketchupBridge extends BaseBridge {
private requests = {} as Record<
string,
{
resolve: (value: unknown) => void
reject: (reason: string | Error) => void
rejectTimerId: number
}
>
private bindingName: string
private TIMEOUT_MS = 1000 * 60 // 60 sec
private NON_TIMEOUT_METHODS = ['send', 'afterGetObjects']
public isInitalized: Promise<boolean>
private resolveIsInitializedPromise!: (v: boolean) => unknown
private rejectIsInitializedPromise!: (message: string) => unknown
constructor(bindingName: string) {
super()
this.bindingName = bindingName || 'default_bindings'
this.isInitalized = new Promise((resolve, reject) => {
this.resolveIsInitializedPromise = resolve
this.rejectIsInitializedPromise = reject
setTimeout(
() =>
reject(
`Failed to get command names from Sketchup; timed out after ${this.TIMEOUT_MS}ms.`
),
this.TIMEOUT_MS
)
})
// NOTE: we need to hoist the bindings in global scope BEFORE we call sketchup exec get comands below.
;(globalThis as Record<string, unknown>).bindings = this
}
// NOTE: Overriden emit as we do not need to parse the data back - the Sketchup bridge already parses it for us.
override emit(eventName: string, payload: string): void {
const eventPayload = payload as unknown as Record<string, unknown>
if (eventName === 'sendViaBrowser')
this.sendViaBrowser(eventPayload as SendViaBrowserArgs)
else if (eventName === 'sendBatchViaBrowser')
this.sendBatchViaBrowser(eventPayload as SendBatchViaBrowserArgs)
else if (eventName === 'createVersionViaBrowser')
this.createVersionViaBrowser(eventPayload as CreateVersionViaBrowserArgs)
else if (eventName === 'receiveViaBrowser')
this.receiveViaBrowser(eventPayload as ReceiveViaBrowserArgs)
return this.emitter.emit(eventName, eventPayload)
}
private async receiveViaBrowser(eventPayload: ReceiveViaBrowserArgs) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find(
(acc) => acc.accountInfo.id === eventPayload.accountId
)
provideApolloClient((account as DUIAccount).client)
// useQuery cannot use in outside of VueComponent.
const result = await (account as DUIAccount).client.query({
query: versionDetailsQuery,
variables: {
projectId: eventPayload.projectId,
versionId: eventPayload.selectedVersionId,
modelId: eventPayload.modelId
}
})
const loader = new ObjectLoader({
serverUrl: account?.accountInfo.serverInfo.url as string,
token: account?.accountInfo.token as string,
streamId: eventPayload.projectId,
objectId: result.data.project.model.version.referencedObject as string
})
const updateProgress = (e: {
stage: ProgressStage
current: number
total: number
}) => {
const progress = e.current / e.total
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: { status: 'Downloading', progress }
})
}
// eslint-disable-next-line @typescript-eslint/await-thenable
const rootObj = await loader.getAndConstructObject(updateProgress)
const args = [
eventPayload.modelCardId,
result.data.project.model.version.sourceApplication,
rootObj
]
const markReceived = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(markReceivedVersionMutation)
)
await markReceived.mutate({
input: {
versionId: eventPayload.selectedVersionId,
projectId: eventPayload.projectId,
sourceApplication: 'sketchup'
}
})
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: {
status: 'Conversion has started, SketchUp may be unresponsive for a while.'
}
})
// CONVERSION WILL START AFTER THAT
await this.runMethod('afterGetObjects', args as unknown as unknown[])
}
/**
* Internal sketchup method for sending batch data via the browser.
* @param eventPayload
*/
private async sendBatchViaBrowser(eventPayload: SendBatchViaBrowserArgs) {
const {
serverUrl,
token,
projectId,
modelCardId,
batch,
totalBatch,
currentBatch,
referencedObjectId
} = eventPayload
if (currentBatch === 1) {
this.emit('setModelProgress', {
modelCardId,
progress: {
status: 'Uploading',
progress: 0
}
} as unknown as string)
}
const formData = new FormData()
formData.append(`batch-1`, new Blob([batch], { type: 'application/json' }))
await fetch(`${serverUrl}/objects/${projectId}`, {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: formData
})
this.emit('setModelProgress', {
modelCardId,
progress: {
status: 'Uploading',
progress: currentBatch / totalBatch
}
} as unknown as string)
if (currentBatch === totalBatch) {
const args = [eventPayload.modelCardId, referencedObjectId]
await this.runMethod('afterSendObjects', args as unknown as unknown[])
}
}
private async createVersionViaBrowser(eventPayload: CreateVersionViaBrowserArgs) {
const {
projectId,
accountId,
modelId,
modelCardId,
referencedObjectId,
message,
sendConversionResults
} = eventPayload
const args: CreateVersionArgs = {
modelCardId,
projectId,
modelId,
accountId,
referencedObjectId,
sourceApplication: 'sketchup',
message: message || 'send from sketchup'
}
const versionId = await this.createVersion(args)
const hostAppStore = useHostAppStore()
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
}
/**
* Internal sketchup method for sending data via the browser.
* @param eventPayload
* @deprecated replaced with sendBatchViaBrowser. NOTE: remove completely!
*/
private async sendViaBrowser(eventPayload: SendViaBrowserArgs) {
const {
serverUrl,
token,
projectId,
accountId,
modelId,
modelCardId,
sendObject,
sendConversionResults,
message
} = eventPayload
this.emit('setModelProgress', {
modelCardId,
progress: {
status: 'Uploading',
progress: 0
}
} as unknown as string)
// TODO: More of a question: why are we not sending multiple batches at once?
// What's in a batch? etc. To look at optmizing this and not blocking the
// main thread.
const promises = [] as Promise<Response>[]
sendObject.batches.forEach((batch) => {
const formData = new FormData()
formData.append(`batch-1`, new Blob([batch], { type: 'application/json' }))
promises.push(
fetch(`${serverUrl}/objects/${projectId}`, {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: formData
})
)
})
await Promise.all(promises)
const args: CreateVersionArgs = {
modelCardId,
projectId,
modelId,
accountId,
referencedObjectId: sendObject.id,
sourceApplication: 'sketchup',
message: message || 'send from sketchup'
}
const versionId = await this.createVersion(args)
const hostAppStore = useHostAppStore()
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
}
public async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
public async create(): Promise<boolean> {
// Initialization continues in the receiveCommandsAndInitializeBridge function,
// where we expect sketchup to return to us the command names for related bindings/views.
sketchup.getBindingsMethodNames(this.bindingName)
//
try {
await this.isInitalized
return true
} catch {
return false
}
}
/**
* Will be called by `executeScript('bindings.receiveCommandsAndInitializeBridge()')` from sketchup. This is where the hoisting happens.
* NOTE: Oguhzan, we can defintively have commandNames be a string, and not a string[]
* And do JSON.parse() here to get them out properly.
* @param commandNames
*/
private receiveCommandsAndInitializeBridge(commandNamesString: string) {
const commandNames = JSON.parse(commandNamesString) as string[]
const hoistTarget = this as unknown as Record<string, unknown>
this.availableMethodNames = commandNames
for (const commandName of commandNames) {
hoistTarget[commandName] = (...args: unknown[]) =>
this.runMethod(commandName, args)
}
this.resolveIsInitializedPromise(true)
}
/**
* Will be called by `executeScript('bindings.rejectBindings()')` from sketchup.
* @param message
*/
private rejectBindings(message: string) {
this.rejectIsInitializedPromise(message)
}
/**
* Internal calls to Sketchup.
* @param methodName
* @param args
*/
private async runMethod(methodName: string, args: unknown[]): Promise<unknown> {
const requestId = uniqueId(this.bindingName)
// TODO: more on the ruby end, but for now Oguzhan seems happy with this.
// Changes might be needed in the future.
sketchup.exec({
name: methodName,
// eslint-disable-next-line camelcase
request_id: requestId,
// eslint-disable-next-line camelcase
binding_name: this.bindingName,
data: { args }
})
return new Promise((resolve, reject) => {
this.requests[requestId] = {
resolve,
reject,
rejectTimerId: window.setTimeout(
() => {
reject(
`Sketchup response timed out for ${methodName} - did not receive anything back in good time (${this.TIMEOUT_MS}ms).`
)
delete this.requests[requestId]
},
this.NON_TIMEOUT_METHODS.includes(methodName) ? 3600000 : this.TIMEOUT_MS
)
}
})
}
private receiveResponse(requestId: string, data: object) {
if (!this.requests[requestId])
throw new Error(
`Sketchup Bridge found no request to resolve with the id of ${requestId}. Something is weird!`
)
const request = this.requests[requestId]
try {
// eslint-disable-next-line no-prototype-builtins
if (data && data.hasOwnProperty('error')) {
console.error(data)
this.emitter.emit('errorOnResponse', JSON.stringify(data))
throw new Error(
`Failed to run ${requestId}. The host app error is logged above.`
)
}
// NOTE/TODO: does not need parsing
// const parsedData = JSON.parse(data) as Record<string, unknown> // TODO: check if data is undefined
request.resolve(data)
} catch (e) {
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
public showDevTools() {
// eslint-disable-next-line no-alert
window.alert(
'Sketchup cannot do this. The dev tools menu is accessible via a right click.'
)
}
public openUrl(url: string) {
window.open(url)
}
}