import type { AppRecord, AppRecordOptions, BackendContext, DevtoolsBackend, SimpleAppRecord, } from '@vue-devtools/app-backend-api' import { BridgeEvents, SharedData, isBrowser } from '@vue-devtools/shared-utils' import type { App } from '@vue/devtools-api' import slug from 'speakingurl' import { JobQueue } from './util/queue' import { scan } from './legacy/scan' import { addBuiltinLayers, removeLayersForApp } from './timeline' import { availableBackends, getBackend } from './backend' import { hook } from './global-hook.js' import { sendComponentTreeData, sendSelectedComponentData } from './component.js' const jobs = new JobQueue() let recordId = 0 type AppRecordResolver = (record: AppRecord) => void | Promise const appRecordPromises = new Map() export async function registerApp(options: AppRecordOptions, ctx: BackendContext) { return jobs.queue('regiserApp', () => registerAppJob(options, ctx)) } async function registerAppJob(options: AppRecordOptions, ctx: BackendContext) { // Dedupe if (ctx.appRecords.find(a => a.options.app === options.app)) { return } if (!options.version) { throw new Error('[Vue Devtools] Vue version not found') } // Find correct backend const baseFrameworkVersion = Number.parseInt(options.version.substring(0, options.version.indexOf('.'))) for (let i = 0; i < availableBackends.length; i++) { const backendOptions = availableBackends[i] if (backendOptions.frameworkVersion === baseFrameworkVersion) { // Enable backend if it's not enabled const backend = getBackend(backendOptions, ctx) await createAppRecord(options, backend, ctx) break } } } async function createAppRecord(options: AppRecordOptions, backend: DevtoolsBackend, ctx: BackendContext) { const rootInstance = await backend.api.getAppRootInstance(options.app) if (rootInstance) { if ((await backend.api.getComponentDevtoolsOptions(rootInstance)).hide) { options.app._vueDevtools_hidden_ = true return } recordId++ const name = await backend.api.getAppRecordName(options.app, recordId.toString()) const id = getAppRecordId(options.app, slug(name)) const [el]: HTMLElement[] = await backend.api.getComponentRootElements(rootInstance) const instanceMapRaw = new Map() const record: AppRecord = { id, name, options, backend, lastInspectedComponentId: null, instanceMap: new Proxy(instanceMapRaw, { get(target, key: string) { if (key === 'set') { return (instanceId: string, instance: any) => { target.set(instanceId, instance) // The component was requested by the frontend before it was registered if (record.missingInstanceQueue.has(instanceId)) { record.missingInstanceQueue.delete(instanceId) if (ctx.currentAppRecord === record) { sendComponentTreeData(record, instanceId, record.componentFilter, null, false, ctx) if (record.lastInspectedComponentId === instanceId) { sendSelectedComponentData(record, instanceId, ctx) } } } } } return target[key].bind(target) }, }), rootInstance, perfGroupIds: new Map(), iframe: isBrowser && document !== el?.ownerDocument ? el?.ownerDocument?.location?.pathname : null, meta: options.meta ?? {}, missingInstanceQueue: new Set(), } options.app.__VUE_DEVTOOLS_APP_RECORD__ = record const rootId = `${record.id}:root` record.instanceMap.set(rootId, record.rootInstance) record.rootInstance.__VUE_DEVTOOLS_UID__ = rootId // Timeline addBuiltinLayers(record, ctx) ctx.appRecords.push(record) if (backend.options.setupApp) { backend.options.setupApp(backend.api, record) } await backend.api.registerApplication(options.app) ctx.bridge.send(BridgeEvents.TO_FRONT_APP_ADD, { appRecord: mapAppRecord(record), }) // Auto select first app if (ctx.currentAppRecord == null) { await selectApp(record, ctx) } if (appRecordPromises.has(options.app)) { for (const r of appRecordPromises.get(options.app)) { await r(record) } } } else if (SharedData.debugInfo) { console.warn('[Vue devtools] No root instance found for app, it might have been unmounted', options.app) } } export async function selectApp(record: AppRecord, ctx: BackendContext) { ctx.currentAppRecord = record ctx.currentInspectedComponentId = record.lastInspectedComponentId ctx.bridge.send(BridgeEvents.TO_FRONT_APP_SELECTED, { id: record.id, lastInspectedComponentId: record.lastInspectedComponentId, }) } export function mapAppRecord(record: AppRecord): SimpleAppRecord { return { id: record.id, name: record.name, version: record.options.version, iframe: record.iframe, } } const appIds = new Set() export function getAppRecordId(app, defaultId?: string): string { if (app.__VUE_DEVTOOLS_APP_RECORD_ID__ != null) { return app.__VUE_DEVTOOLS_APP_RECORD_ID__ } let id = defaultId ?? (recordId++).toString() if (defaultId && appIds.has(id)) { let count = 1 while (appIds.has(`${defaultId}_${count}`)) { count++ } id = `${defaultId}_${count}` } appIds.add(id) app.__VUE_DEVTOOLS_APP_RECORD_ID__ = id return id } export async function getAppRecord(app: any, ctx: BackendContext): Promise { const record = app.__VUE_DEVTOOLS_APP_RECORD__ ?? ctx.appRecords.find(ar => ar.options.app === app) if (record) { return record } if (app._vueDevtools_hidden_) { return null } return new Promise((resolve, reject) => { let resolvers = appRecordPromises.get(app) let timedOut = false if (!resolvers) { resolvers = [] appRecordPromises.set(app, resolvers) } let timer: any const fn = (record) => { if (!timedOut) { clearTimeout(timer) resolve(record) } } resolvers.push(fn) timer = setTimeout(() => { timedOut = true const index = resolvers.indexOf(fn) if (index !== -1) { resolvers.splice(index, 1) } if (SharedData.debugInfo) { // eslint-disable-next-line no-console console.log('Timed out waiting for app record', app) } reject(new Error(`Timed out getting app record for app`)) }, 60000) }) } export function waitForAppsRegistration() { return jobs.queue('waitForAppsRegistrationNoop', async () => { /* NOOP */ }) } export async function sendApps(ctx: BackendContext) { const appRecords = [] for (const appRecord of ctx.appRecords) { appRecords.push(appRecord) } ctx.bridge.send(BridgeEvents.TO_FRONT_APP_LIST, { apps: appRecords.map(mapAppRecord), }) } function removeAppRecord(appRecord: AppRecord, ctx: BackendContext) { try { appIds.delete(appRecord.id) const index = ctx.appRecords.indexOf(appRecord) if (index !== -1) { ctx.appRecords.splice(index, 1) } removeLayersForApp(appRecord.options.app, ctx) ctx.bridge.send(BridgeEvents.TO_FRONT_APP_REMOVE, { id: appRecord.id }) } catch (e) { if (SharedData.debugInfo) { console.error(e) } } } export async function removeApp(app: App, ctx: BackendContext) { try { const appRecord = await getAppRecord(app, ctx) if (appRecord) { removeAppRecord(appRecord, ctx) } } catch (e) { if (SharedData.debugInfo) { console.error(e) } } } let scanTimeout: any export function _legacy_getAndRegisterApps(ctx: BackendContext, clear = false) { setTimeout(() => { try { if (clear) { // Remove apps that are legacy ctx.appRecords.forEach((appRecord) => { if (appRecord.meta.Vue) { removeAppRecord(appRecord, ctx) } }) } const apps = scan() clearTimeout(scanTimeout) if (!apps.length) { scanTimeout = setTimeout(() => _legacy_getAndRegisterApps(ctx), 1000) } apps.forEach((app) => { const Vue = hook.Vue registerApp({ app, types: {}, version: Vue?.version, meta: { Vue, }, }, ctx) }) } catch (e) { if (SharedData.debugInfo) { console.error(`Error scanning for legacy apps:`) console.error(e) } } }, 0) }