diff --git a/package-lock.json b/package-lock.json index f49d70ecec19..93fce5f51916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23947,6 +23947,9 @@ "name": "@babylonjs/inspector", "version": "9.1.0", "license": "Apache-2.0", + "bin": { + "babylon-inspector": "bin/inspector-cli.mjs" + }, "devDependencies": { "@dev/build-tools": "^1.0.0", "@dev/inspector": "1.0.0", @@ -23954,9 +23957,11 @@ "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", + "@types/ws": "^8.5.0", "chalk": "^5.3.0", "rollup": "^4.59.0", - "rollup-plugin-dts": "^6.1.1" + "rollup-plugin-dts": "^6.1.1", + "ws": "^8.18.0" }, "peerDependencies": { "@babylonjs/addons": "^9.0.0", @@ -23974,6 +23979,28 @@ "usehooks-ts": "^3.1.1" } }, + "packages/public/@babylonjs/inspector-v2/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/public/@babylonjs/ktx2decoder": { "version": "9.1.0", "license": "Apache-2.0", diff --git a/packages/dev/core/src/Meshes/mesh.ts b/packages/dev/core/src/Meshes/mesh.ts index 92764e749288..f6ef5e2d91ba 100644 --- a/packages/dev/core/src/Meshes/mesh.ts +++ b/packages/dev/core/src/Meshes/mesh.ts @@ -4188,7 +4188,7 @@ export class Mesh extends AbstractMesh implements IGetSetVerticesData { // Physics //TODO implement correct serialization for physics impostors. if (this.getScene()._getComponent(SceneComponentConstants.NAME_PHYSICSENGINE)) { - const impostor = this.getPhysicsImpostor(); + const impostor = this.getPhysicsImpostor?.(); if (impostor) { serializationObject.physicsMass = impostor.getParam("mass"); serializationObject.physicsFriction = impostor.getParam("friction"); @@ -4234,7 +4234,7 @@ export class Mesh extends AbstractMesh implements IGetSetVerticesData { // Physics //TODO implement correct serialization for physics impostors. if (this.getScene()._getComponent(SceneComponentConstants.NAME_PHYSICSENGINE)) { - const impostor = instance.getPhysicsImpostor(); + const impostor = instance.getPhysicsImpostor?.(); if (impostor) { serializationInstance.physicsMass = impostor.getParam("mass"); serializationInstance.physicsFriction = impostor.getParam("friction"); diff --git a/packages/dev/inspector-v2/package.json b/packages/dev/inspector-v2/package.json index d7108a5aaa46..b342935fc90a 100644 --- a/packages/dev/inspector-v2/package.json +++ b/packages/dev/inspector-v2/package.json @@ -14,7 +14,8 @@ "serve": "webpack serve --mode development", "watch": "tsc -b tsconfig.build.json -w", "watch:dev": "npm run watch", - "makeAvatar": "node scripts/makeAvatar.mjs" + "makeAvatar": "node scripts/makeAvatar.mjs", + "babylon-inspector": "node --no-warnings dist/cli/cli.js --bridge-script dist/cli/bridge.js" }, "devDependencies": { "@dev/addons": "1.0.0", diff --git a/packages/dev/inspector-v2/src/cli/bridge.ts b/packages/dev/inspector-v2/src/cli/bridge.ts new file mode 100644 index 000000000000..477f2e8a1c51 --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/bridge.ts @@ -0,0 +1,258 @@ +/* eslint-disable no-console */ +import { fileURLToPath } from "url"; +import { type WebSocket, WebSocketServer } from "./webSocket.js"; +import { LoadConfig } from "./config.js"; +import { type BrowserRequest, type BrowserResponse, type CliRequest, type CliResponse, type SessionInfo } from "./protocol.js"; + +interface ISession extends SessionInfo { + /** The WebSocket connection for this session. */ + ws: WebSocket; +} + +/** + * Configuration for starting the bridge. + */ +export interface IBridgeConfig { + /** WebSocket port for browser sessions. Use 0 for OS-assigned port. */ + browserPort: number; + /** WebSocket port for CLI connections. Use 0 for OS-assigned port. */ + cliPort: number; + /** Timeout in ms for waiting for an initial session on a sessions request. Defaults to 5000. */ + sessionWaitTimeoutMs?: number; +} + +/** + * Handle returned by {@link startBridge} to control and inspect the running bridge. + */ +export interface IBridgeHandle { + /** The actual port the browser WebSocket server is listening on. */ + browserPort: number; + /** The actual port the CLI WebSocket server is listening on. */ + cliPort: number; + /** Shuts down the bridge, closing all connections and servers. */ + shutdown: () => void; +} + +/** + * Starts the Inspector bridge with the given configuration. + * @param config The ports to listen on. Use port 0 for OS-assigned ports. + * @returns A promise that resolves with a handle to control the running bridge. + */ +export async function StartBridge(config: IBridgeConfig): Promise { + let nextSessionId = 1; + const sessions = new Map(); + const pendingBrowserRequests = new Map void>(); + const sessionAddedListeners: (() => void)[] = []; + let requestCounter = 0; + + function generateRequestId(): string { + return `bridge-req-${++requestCounter}`; + } + + const sessionWaitTimeout = config.sessionWaitTimeoutMs ?? 5000; + + async function waitForSession(): Promise { + if (sessions.size > 0) { + return; + } + return await new Promise((resolve) => { + const timer = setTimeout(() => { + const index = sessionAddedListeners.indexOf(listener); + if (index !== -1) { + sessionAddedListeners.splice(index, 1); + } + resolve(); + }, sessionWaitTimeout); + + const listener = () => { + clearTimeout(timer); + const index = sessionAddedListeners.indexOf(listener); + if (index !== -1) { + sessionAddedListeners.splice(index, 1); + } + resolve(); + }; + sessionAddedListeners.push(listener); + }); + } + + async function waitForBrowserResponse(requestId: string, timeoutMs = 30000): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingBrowserRequests.delete(requestId); + reject(new Error("Timeout")); + }, timeoutMs); + + pendingBrowserRequests.set(requestId, (response) => { + clearTimeout(timer); + resolve(response); + }); + }); + } + + // Browser-facing WebSocket server. + const browserWss = new WebSocketServer({ host: "127.0.0.1", port: config.browserPort }); + + // CLI-facing WebSocket server. + const cliWss = new WebSocketServer({ host: "127.0.0.1", port: config.cliPort }); + + browserWss.on("connection", (socket) => { + let session: ISession | null = null; + + socket.on("message", (data) => { + let message: BrowserRequest; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + switch (message.type) { + case "register": { + const id = nextSessionId++; + session = { + id, + name: message.name, + connectedAt: new Date().toISOString(), + ws: socket, + }; + sessions.set(id, session); + console.log(`Session ${id} registered: "${session.name}"`); + for (const listener of sessionAddedListeners.splice(0)) { + listener(); + } + break; + } + case "commandListResponse": + case "commandResponse": { + // Forward response back to the CLI that requested it. + const resolve = pendingBrowserRequests.get(message.requestId); + if (resolve) { + pendingBrowserRequests.delete(message.requestId); + resolve(JSON.stringify(message)); + } + break; + } + } + }); + + socket.on("close", () => { + if (session) { + console.log(`Session ${session.id} disconnected: "${session.name}"`); + sessions.delete(session.id); + } + }); + }); + + cliWss.on("connection", (socket) => { + socket.on("message", async (data) => { + let message: CliRequest; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + function sendCliResponse(response: CliResponse) { + socket.send(JSON.stringify(response)); + } + + function sendBrowserRequest(target: ISession, request: BrowserResponse) { + target.ws.send(JSON.stringify(request)); + } + + switch (message.type) { + case "sessions": { + // Wait for at least one session to connect before responding. + await waitForSession(); + const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({ + id: s.id, + name: s.name, + connectedAt: s.connectedAt, + })); + sendCliResponse({ type: "sessionsResponse", sessions: sessionList }); + break; + } + case "commands": { + const session = sessions.get(message.sessionId); + if (!session) { + sendCliResponse({ type: "commandsResponse", error: `No session with id ${message.sessionId}` }); + break; + } + const requestId = generateRequestId(); + sendBrowserRequest(session, { type: "listCommands", requestId }); + try { + const response = await waitForBrowserResponse(requestId); + socket.send(response); + } catch { + sendCliResponse({ type: "commandsResponse", error: "Timeout waiting for browser response" }); + } + break; + } + case "exec": { + const session = sessions.get(message.sessionId); + if (!session) { + sendCliResponse({ type: "execResponse", error: `No session with id ${message.sessionId}` }); + break; + } + const requestId = generateRequestId(); + sendBrowserRequest(session, { + type: "execCommand", + requestId, + commandId: message.commandId, + args: message.args, + }); + try { + const response = await waitForBrowserResponse(requestId); + socket.send(response); + } catch { + sendCliResponse({ type: "execResponse", error: "Timeout waiting for browser response" }); + } + break; + } + case "stop": { + sendCliResponse({ type: "stopResponse", success: true }); + shutdown(); + break; + } + } + }); + }); + + function shutdown(): void { + console.log("Inspector bridge shutting down."); + + for (const session of sessions.values()) { + session.ws.close(); + } + sessions.clear(); + + browserWss.close(); + cliWss.close(); + } + + // Wait for both servers to be listening before returning. + await Promise.all([new Promise((resolve) => browserWss.on("listening", resolve)), new Promise((resolve) => cliWss.on("listening", resolve))]); + + const actualBrowserPort = (browserWss.address() as import("net").AddressInfo).port; + const actualCliPort = (cliWss.address() as import("net").AddressInfo).port; + + console.log(`Inspector bridge started.`); + console.log(` Browser port: ${actualBrowserPort}`); + console.log(` CLI port: ${actualCliPort}`); + + return { + browserPort: actualBrowserPort, + cliPort: actualCliPort, + shutdown, + }; +} + +// Auto-start when run directly (not when imported for testing). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void (async () => { + const handle = await StartBridge(LoadConfig()); + process.on("SIGTERM", () => handle.shutdown()); + process.on("SIGINT", () => handle.shutdown()); + })(); +} diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts new file mode 100644 index 000000000000..804b6f5e6ab1 --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -0,0 +1,462 @@ +/* eslint-disable no-console */ +import { spawn } from "child_process"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { parseArgs } from "util"; +import { WebSocket } from "./webSocket.js"; +import { LoadConfig } from "./config.js"; +import { + type CliRequest, + type CliResponse, + type CommandArgInfo, + type CommandInfo, + type CommandsResponse, + type ExecResponse, + type SessionInfo, + type SessionsResponse, +} from "./protocol.js"; + +const Config = LoadConfig(); + +const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes from the terminal. + +USAGE + babylon-inspector [options] + babylon-inspector --command [--arg value ...] + +OPTIONS + --help Show this help message. + --session [session-id] List active sessions, or specifies the target session. + A session id is only needed when multiple sessions + are active. + --command [command-id] List available commands, or execute one. + Use --command --help to see its arguments. + --stop Stop the bridge process. + +CONFIGURATION + Place a .babyloninspector JSON file anywhere in the directory parent chain: + { "browserPort": 4400, "cliPort": 4401 } + +EXAMPLES + babylon-inspector --session + babylon-inspector --command + babylon-inspector --session 2 --command + babylon-inspector --command query-mesh --help + babylon-inspector --command query-mesh --uniqueId 42 + babylon-inspector --session 2 --command query-mesh --uniqueId 42 +`; + +const KnownOptions = new Set(["help", "stop", "session", "command", "bridge-script"]); + +/** + * Parsed CLI arguments for the Inspector CLI. + */ +export interface IParsedArgs { + /** Whether the user requested help. */ + help: boolean; + /** Whether --session was specified. */ + session: boolean; + /** The session id value, if provided. */ + sessionId?: string; + /** Whether the user requested the bridge to stop. */ + stop: boolean; + /** Whether --command was specified. */ + command: boolean; + /** Optional path to the bridge script. */ + bridgeScript?: string; + /** Remaining positional and unknown arguments (command id + command args). */ + rest: string[]; +} + +/** + * Parses the CLI arguments into a structured object. + * @param argv Optional array of arguments to parse. Defaults to process.argv. + * @returns The parsed arguments. + */ +export function ParseCliArgs(argv?: string[]): IParsedArgs { + const { values, tokens } = parseArgs({ + options: { + help: { type: "boolean", default: false }, + session: { type: "boolean", default: false }, + stop: { type: "boolean", default: false }, + command: { type: "boolean", default: false }, + // eslint-disable-next-line @typescript-eslint/naming-convention + "bridge-script": { type: "string" }, + }, + strict: false, + allowPositionals: true, + tokens: true, + ...(argv !== undefined ? { args: argv } : {}), + }); + + // Walk the token stream to extract: + // - The first positional after --session as sessionId (if it's a number) + // - Remaining positionals and unknown --key value pairs into rest + let sessionId: string | undefined; + const rest: string[] = []; + + if (tokens) { + let expectingSessionId = false; + let pendingOptionName: string | null = null; + for (const token of tokens) { + // After seeing --session, the next positional (if numeric) is the session id. + if (token.kind === "option" && token.name === "session") { + expectingSessionId = true; + continue; + } + + if (expectingSessionId && token.kind === "positional" && !isNaN(parseInt(token.value, 10))) { + sessionId = token.value; + expectingSessionId = false; + continue; + } + expectingSessionId = false; + + if (token.kind === "option" && !KnownOptions.has(token.name)) { + if (pendingOptionName !== null) { + rest.push(`--${pendingOptionName}`); + } + if (token.value !== undefined) { + rest.push(`--${token.name}`, token.value); + } else { + pendingOptionName = token.name; + } + continue; + } + if (token.kind === "positional" && pendingOptionName !== null) { + rest.push(`--${pendingOptionName}`, token.value); + pendingOptionName = null; + continue; + } + if (token.kind === "positional") { + rest.push(token.value); + } + } + if (pendingOptionName !== null) { + rest.push(`--${pendingOptionName}`); + } + } + + return { + help: !!values.help, + session: !!values.session, + sessionId, + stop: !!values.stop, + command: !!values.command, + bridgeScript: values["bridge-script"] as string | undefined, + rest, + }; +} + +async function ConnectToBridge(port: number): Promise { + return await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + socket.on("open", () => resolve(socket)); + socket.on("error", (err) => reject(err)); + }); +} + +/** + * Sends a JSON message to the bridge over WebSocket and waits for a response. + * @param socket The WebSocket connection to the bridge. + * @param message The CLI request to send. + * @returns The parsed bridge response. + */ +export async function SendAndReceive(socket: WebSocket, message: CliRequest): Promise { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for bridge response.")); + }, 15000); + + socket.once("message", (data) => { + clearTimeout(timeout); + try { + resolve(JSON.parse(data.toString()) as T); + } catch { + reject(new Error("Failed to parse bridge response.")); + } + }); + + socket.send(JSON.stringify(message)); + }); +} + +function SpawnBridge(bridgeScript?: string): void { + const bridgePath = bridgeScript ? resolve(bridgeScript) : join(dirname(fileURLToPath(import.meta.url)), "inspector-bridge.mjs"); + const child = spawn(process.execPath, [bridgePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); +} + +async function EnsureBridge(port: number, bridgeScript?: string, maxRetries = 10, retryDelayMs = 500): Promise { + try { + return await ConnectToBridge(port); + } catch { + // Bridge not running — spawn it. + SpawnBridge(bridgeScript); + } + + for (let i = 0; i < maxRetries; i++) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + try { + // eslint-disable-next-line no-await-in-loop + return await ConnectToBridge(port); + } catch { + // Keep retrying. + } + } + + throw new Error(`Unable to connect to the Inspector bridge on port ${port} after spawning it.`); +} + +/** + * Connects to the bridge, runs the provided callback, and closes the socket. + * @param bridgeScript Optional path to the bridge script. + * @param fn The callback to run with the connected socket. + */ +async function WithBridge(bridgeScript: string | undefined, fn: (socket: WebSocket) => Promise): Promise { + const socket = await EnsureBridge(Config.cliPort, bridgeScript); + try { + await fn(socket); + } finally { + socket.close(); + } +} + +/** + * Resolves the session id to use. If an explicit id is provided, returns it. + * If not, queries the bridge: returns the sole session's id when exactly one + * is active, or errors if zero or multiple sessions are active. + * @param socket The WebSocket connection to the bridge. + * @param explicitId An optional explicit session id string. + * @returns The resolved numeric session id. + */ +/** + * Parses and validates an explicit session id string against the list of active sessions. + * @param explicitId The session id string to validate. + * @param sessions The list of active sessions from the bridge. + * @returns The matching session info. + */ +export function ValidateSessionId(explicitId: string, sessions: SessionInfo[]): SessionInfo { + const parsed = parseInt(explicitId, 10); + if (isNaN(parsed)) { + throw new Error("Session id must be a number."); + } + const match = sessions.find((s) => s.id === parsed); + if (!match) { + if (sessions.length === 0) { + throw new Error(`Session ${parsed} does not exist. No active sessions.`); + } + const list = sessions.map((s) => ` [${s.id}] ${s.name}`).join("\n"); + throw new Error(`Session ${parsed} does not exist. Active sessions:\n${list}`); + } + return match; +} + +/** + * Resolves the session id to use. If an explicit id is provided, validates it + * against the active sessions. If not, queries the bridge: returns the sole + * session's id when exactly one is active, or errors if zero or multiple. + * @param socket The WebSocket connection to the bridge. + * @param explicitId An optional explicit session id string. + * @returns The resolved numeric session id. + */ +export async function ResolveSessionId(socket: WebSocket, explicitId?: string): Promise { + const response = await SendAndReceive(socket, { type: "sessions" }); + + if (explicitId !== undefined) { + return ValidateSessionId(explicitId, response.sessions).id; + } + + if (response.sessions.length === 0) { + throw new Error("No active sessions. Make sure a browser is running with StartInspectable enabled."); + } + if (response.sessions.length > 1) { + const list = response.sessions.map((s) => ` [${s.id}] ${s.name}`).join("\n"); + throw new Error(`Multiple active sessions:\n${list}\nSpecify a session id with --session `); + } + return response.sessions[0].id; +} + +/** + * Parses command arguments from the rest array (everything after the command id). + * @param rest The remaining CLI tokens after the command id. + * @param globalHelp Whether --help was specified at the top level. + * @returns The parsed command arguments and whether help was requested. + */ +export function ParseCommandArgs(rest: string[], globalHelp: boolean): { args: Record; wantsHelp: boolean } { + const args: Record = {}; + let wantsHelp = globalHelp; + for (let i = 1; i < rest.length; i++) { + const token = rest[i]; + if (token === "--help") { + wantsHelp = true; + } else if (token.startsWith("--") && i + 1 < rest.length) { + args[token.slice(2)] = rest[++i]; + } + } + return { args, wantsHelp }; +} + +/** + * Prints help text for a command, including its description and argument list. + * If there are missing required arguments and help was not explicitly requested, + * prints an error and sets a non-zero exit code. + * @param commandId The command identifier. + * @param descriptor The command descriptor. + * @param missingRequired The list of missing required arguments. + * @param wantsHelp Whether help was explicitly requested. + */ +export function PrintCommandHelp(commandId: string, descriptor: CommandInfo, missingRequired: CommandArgInfo[], wantsHelp: boolean): void { + if (missingRequired.length > 0 && !wantsHelp) { + console.error(`Missing required argument(s): ${missingRequired.map((a) => `--${a.name}`).join(", ")}\n`); + } + console.log(`${commandId}: ${descriptor.description}\n`); + if (descriptor.args && descriptor.args.length > 0) { + console.log("Arguments:"); + const maxLen = Math.max(...descriptor.args.map((a) => `--${a.name}${a.required ? " (required)" : ""}`.length)); + for (const arg of descriptor.args) { + const label = `--${arg.name}${arg.required ? " (required)" : ""}`; + console.log(` ${label.padEnd(maxLen)} ${arg.description}`); + } + } + if (missingRequired.length > 0 && !wantsHelp) { + process.exitCode = 1; + } +} + +/** + * Handles `--session` without `--command`: lists active sessions, or shows + * details for a specific session when an explicit id is provided. + * @param socket The WebSocket connection to the bridge. + * @param explicitId An optional explicit session id string. + */ +async function HandleSessions(socket: WebSocket, explicitId?: string): Promise { + const response = await SendAndReceive(socket, { type: "sessions" }); + + if (explicitId !== undefined) { + const match = ValidateSessionId(explicitId, response.sessions); + console.log(` [${match.id}] ${match.name} (connected: ${match.connectedAt})`); + return; + } + + if (response.sessions.length === 0) { + console.log("No active sessions."); + } else { + console.log("Active sessions:"); + for (const session of response.sessions) { + console.log(` [${session.id}] ${session.name} (connected: ${session.connectedAt})`); + } + } +} + +/** + * Handles `--command`: lists commands or executes one. + * @param socket The WebSocket connection to the bridge. + * @param args The parsed CLI arguments. + */ +async function HandleCommand(socket: WebSocket, args: IParsedArgs): Promise { + const commandId = args.rest.length > 0 ? args.rest[0] : undefined; + + if (!commandId) { + // --command with no id: list available commands. + const sessionId = await ResolveSessionId(socket, args.sessionId); + const response = await SendAndReceive(socket, { type: "commands", sessionId }); + if (response.error) { + console.error(`Error: ${response.error}`); + process.exitCode = 1; + return; + } + if (!response.commands || response.commands.length === 0) { + console.log("No commands available."); + } else { + console.log("Available commands:"); + const maxLen = Math.max(...response.commands.map((c) => c.id.length)); + for (const cmd of response.commands) { + console.log(` ${cmd.id.padEnd(maxLen)} ${cmd.description}`); + } + console.log("\nRun --command --help to see arguments for a command."); + console.log("Run --command [--arg value ...] to execute a command."); + } + return; + } + + const sessionId = await ResolveSessionId(socket, args.sessionId); + const { args: commandArgs, wantsHelp } = ParseCommandArgs(args.rest, args.help); + + // Fetch the command descriptor to check for --help or missing required args. + const commandsResponse = await SendAndReceive(socket, { type: "commands", sessionId }); + const descriptor = commandsResponse.commands?.find((c) => c.id === commandId); + + if (!descriptor) { + console.error(`Error: Unknown command "${commandId}".`); + process.exitCode = 1; + return; + } + + // Check for --help or missing required arguments. + const missingRequired = (descriptor.args ?? []).filter((a) => a.required && !(a.name in commandArgs)); + if (wantsHelp || missingRequired.length > 0) { + PrintCommandHelp(commandId, descriptor, missingRequired, wantsHelp); + return; + } + + const response = await SendAndReceive(socket, { + type: "exec", + sessionId, + commandId, + args: commandArgs, + }); + if (response.error) { + console.error(`Error: ${response.error}`); + process.exitCode = 1; + } else { + console.log(response.result ?? ""); + } +} + +async function Main(): Promise { + const args = ParseCliArgs(); + + if (args.help && !args.command) { + console.log(HELP_TEXT); + return; + } + + if (args.stop) { + try { + const socket = await ConnectToBridge(Config.cliPort); + await SendAndReceive(socket, { type: "stop" }); + socket.close(); + console.log("Bridge stopped."); + } catch { + console.log("Bridge is not running."); + } + return; + } + + if (args.session && !args.command) { + await WithBridge(args.bridgeScript, async (socket) => await HandleSessions(socket, args.sessionId)); + return; + } + + if (args.command) { + await WithBridge(args.bridgeScript, async (socket) => await HandleCommand(socket, args)); + return; + } + + // No recognized option — show help. + console.log(HELP_TEXT); +} + +void (async () => { + try { + await Main(); + } catch (error: unknown) { + console.error(`Error: ${error}`); + process.exitCode = 1; + } +})(); diff --git a/packages/dev/inspector-v2/src/cli/config.ts b/packages/dev/inspector-v2/src/cli/config.ts new file mode 100644 index 000000000000..3cc9e091bf9f --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/config.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; + +const CONFIG_FILENAME = ".babyloninspector"; + +const DefaultBrowserPort = 4400; +const DefaultCliPort = 4401; + +/** + * Configuration for the Inspector CLI bridge. + */ +export interface IInspectorBridgeConfig { + /** + * WebSocket port for browser sessions to connect to the bridge. + */ + browserPort: number; + + /** + * WebSocket port for CLI connections to the bridge. + */ + cliPort: number; +} + +/** + * Searches for a `.babyloninspector` config file starting from the given directory + * and walking up the parent chain. Returns the path to the first file found, or + * undefined if none is found. + * @param startDir The directory to start searching from. + * @returns The absolute path to the config file, or undefined. + */ +function FindConfigFile(startDir: string): string | undefined { + let current = startDir; + for (;;) { + const candidate = join(current, CONFIG_FILENAME); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + // Reached filesystem root. + return undefined; + } + current = parent; + } +} + +/** + * Loads the Inspector bridge configuration by searching for a `.babyloninspector` + * file in the directory parent chain starting from `cwd`. If no file is found, + * or if fields are missing, defaults are used. + * @param cwd The working directory to start the search from. Defaults to `process.cwd()`. + * @returns The resolved configuration. + */ +export function LoadConfig(cwd?: string): IInspectorBridgeConfig { + const defaults: IInspectorBridgeConfig = { + browserPort: DefaultBrowserPort, + cliPort: DefaultCliPort, + }; + + const configPath = FindConfigFile(cwd ?? process.cwd()); + if (!configPath) { + return defaults; + } + + try { + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { + browserPort: typeof parsed.browserPort === "number" ? parsed.browserPort : defaults.browserPort, + cliPort: typeof parsed.cliPort === "number" ? parsed.cliPort : defaults.cliPort, + }; + } catch { + // If the file is malformed, fall back to defaults. + return defaults; + } +} diff --git a/packages/dev/inspector-v2/src/cli/protocol.ts b/packages/dev/inspector-v2/src/cli/protocol.ts new file mode 100644 index 000000000000..9d87995eef77 --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/protocol.ts @@ -0,0 +1,205 @@ +// ---- Shared types ---- + +/** + * Serializable description of a command argument, used in protocol messages. + */ +export type CommandArgInfo = { + /** The name of the argument. */ + name: string; + /** A human-readable description of the argument. */ + description: string; + /** Whether this argument is required. */ + required?: boolean; +}; + +/** + * Serializable description of a command, used in protocol messages. + */ +export type CommandInfo = { + /** A unique identifier for the command. */ + id: string; + /** A human-readable description of the command. */ + description: string; + /** The arguments this command accepts. */ + args?: CommandArgInfo[]; +}; + +/** + * Serializable description of a session, used in protocol messages. + */ +export type SessionInfo = { + /** The numeric session identifier. */ + id: number; + /** The display name of the session. */ + name: string; + /** ISO 8601 timestamp of when the session connected. */ + connectedAt: string; +}; + +// ---- CLI ↔ Bridge (CLI port) ---- + +/** + * CLI → Bridge: Request the list of active browser sessions. + */ +export type SessionsRequest = { + /** The message type discriminator. */ + type: "sessions"; +}; + +/** + * CLI → Bridge: Request the list of commands available from a session. + */ +export type CommandsRequest = { + /** The message type discriminator. */ + type: "commands"; + /** The session to query for commands. */ + sessionId: number; +}; + +/** + * CLI → Bridge: Execute a command on a session. + */ +export type ExecRequest = { + /** The message type discriminator. */ + type: "exec"; + /** The session to execute the command on. */ + sessionId: number; + /** The identifier of the command to execute. */ + commandId: string; + /** Key-value pairs of arguments for the command. */ + args: Record; +}; + +/** + * CLI → Bridge: Stop the bridge process. + */ +export type StopRequest = { + /** The message type discriminator. */ + type: "stop"; +}; + +/** + * All messages that the CLI sends to the bridge. + */ +export type CliRequest = SessionsRequest | CommandsRequest | ExecRequest | StopRequest; + +/** + * Bridge → CLI: Response with the list of active sessions. + */ +export type SessionsResponse = { + /** The message type discriminator. */ + type: "sessionsResponse"; + /** The list of active sessions. */ + sessions: SessionInfo[]; +}; + +/** + * Bridge → CLI: Response with the list of commands from a session. + */ +export type CommandsResponse = { + /** The message type discriminator. */ + type: "commandsResponse"; + /** The list of available commands, if successful. */ + commands?: CommandInfo[]; + /** An error message, if the request failed. */ + error?: string; +}; + +/** + * Bridge → CLI: Response with the result of a command execution. + */ +export type ExecResponse = { + /** The message type discriminator. */ + type: "execResponse"; + /** The result of the command execution, if successful. */ + result?: string; + /** An error message, if the execution failed. */ + error?: string; +}; + +/** + * Bridge → CLI: Acknowledgement that the bridge is stopping. + */ +export type StopResponse = { + /** The message type discriminator. */ + type: "stopResponse"; + /** Whether the bridge stopped successfully. */ + success: boolean; +}; + +/** + * All messages that the bridge sends to the CLI. + */ +export type CliResponse = SessionsResponse | CommandsResponse | ExecResponse | StopResponse; + +// ---- Bridge ↔ Browser (browser port) ---- + +/** + * Browser → Bridge: Register a new session. + */ +export type RegisterRequest = { + /** The message type discriminator. */ + type: "register"; + /** The display name for this session. */ + name: string; +}; + +/** + * Browser → Bridge: Response to a listCommands request from the bridge. + */ +export type CommandListResponse = { + /** The message type discriminator. */ + type: "commandListResponse"; + /** The identifier of the original request. */ + requestId: string; + /** The list of registered commands. */ + commands: CommandInfo[]; +}; + +/** + * Browser → Bridge: Response to an execCommand request from the bridge. + */ +export type CommandResponse = { + /** The message type discriminator. */ + type: "commandResponse"; + /** The identifier of the original request. */ + requestId: string; + /** The result of the command execution, if successful. */ + result?: string; + /** An error message, if the execution failed. */ + error?: string; +}; + +/** + * All messages that the browser sends to the bridge. + */ +export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse; + +/** + * Bridge → Browser: Request the list of registered commands. + */ +export type ListCommandsRequest = { + /** The message type discriminator. */ + type: "listCommands"; + /** A unique identifier for this request. */ + requestId: string; +}; + +/** + * Bridge → Browser: Request execution of a command. + */ +export type ExecCommandRequest = { + /** The message type discriminator. */ + type: "execCommand"; + /** A unique identifier for this request. */ + requestId: string; + /** The identifier of the command to execute. */ + commandId: string; + /** Key-value pairs of arguments for the command. */ + args: Record; +}; + +/** + * All messages that the bridge sends to the browser. + */ +export type BrowserResponse = ListCommandsRequest | ExecCommandRequest; diff --git a/packages/dev/inspector-v2/src/cli/webSocket.ts b/packages/dev/inspector-v2/src/cli/webSocket.ts new file mode 100644 index 000000000000..40c333e38294 --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/webSocket.ts @@ -0,0 +1,20 @@ +/** + * Re-exports WebSocket and WebSocketServer from the ws package. + * + * ws is a CJS module — named exports like WebSocketServer aren't available + * at runtime when Node auto-detects ESM from tsc output. This module works + * around that by extracting them from the default export and re-exporting + * them as merged type+value pairs. + */ +import ws from "ws"; + +export const WebSocket = ws; +export type WebSocket = import("ws").WebSocket; + +export const WebSocketServer = ( + ws as unknown as { + // eslint-disable-next-line @typescript-eslint/naming-convention + Server: new (options?: import("ws").ServerOptions) => import("ws").WebSocketServer; + } +).Server; +export type WebSocketServer = import("ws").WebSocketServer; diff --git a/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx b/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx index 71889afa2f57..c91c243b5dea 100644 --- a/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx +++ b/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx @@ -15,22 +15,15 @@ import { ChildWindow } from "shared-ui-components/fluent/hoc/childWindow"; import { FileUploadLine } from "shared-ui-components/fluent/hoc/fileUploadLine"; import { type PerfLayoutSize } from "../performanceViewer/graphSupportingTypes"; import { PerformanceViewer } from "../performanceViewer/performanceViewer"; +import { DefaultPerfStrategies, PerfMetadataCategory } from "../../misc/defaultPerfStrategies"; +/** + * Adds default and platform-specific performance collection strategies to the collector. + * @param perfCollector - The performance viewer collector to add strategies to. + */ function AddStrategies(perfCollector: PerformanceViewerCollector) { - perfCollector.addCollectionStrategies(...DefaultStrategiesList); + perfCollector.addCollectionStrategies(...DefaultPerfStrategies); if (PressureObserverWrapper.IsAvailable) { - // Do not enable for now as the Pressure API does not - // report factors at the moment. - // perfCollector.addCollectionStrategies({ - // strategyCallback: PerfCollectionStrategy.ThermalStrategy(), - // category: IPerfMetadataCategory.FrameSteps, - // hidden: true, - // }); - // perfCollector.addCollectionStrategies({ - // strategyCallback: PerfCollectionStrategy.PowerSupplyStrategy(), - // category: IPerfMetadataCategory.FrameSteps, - // hidden: true, - // }); perfCollector.addCollectionStrategies({ strategyCallback: PerfCollectionStrategy.PressureStrategy(), category: PerfMetadataCategory.FrameSteps, @@ -39,37 +32,6 @@ function AddStrategies(perfCollector: PerformanceViewerCollector) { } } -const enum PerfMetadataCategory { - Count = "Count", - FrameSteps = "Frame Steps Duration", -} - -// list of strategies to add to perf graph automatically. -const DefaultStrategiesList = [ - { strategyCallback: PerfCollectionStrategy.FpsStrategy() }, - { strategyCallback: PerfCollectionStrategy.TotalMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveIndicesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveBonesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveParticlesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.DrawCallsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalLightsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalVerticesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalMaterialsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalTexturesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.AbsoluteFpsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.MeshesSelectionStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.RenderTargetsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ParticlesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.SpritesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.AnimationsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.PhysicsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.RenderStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.FrameTotalStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.InterFrameStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.GpuFrameTimeStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, -] as const; - // arbitrary window size const InitialWindowSize = { width: 1024, height: 512 }; const InitialGraphSize = new Vector2(724, 512); diff --git a/packages/dev/inspector-v2/src/index.ts b/packages/dev/inspector-v2/src/index.ts index c2d12770597f..60c7f18b2b29 100644 --- a/packages/dev/inspector-v2/src/index.ts +++ b/packages/dev/inspector-v2/src/index.ts @@ -48,6 +48,9 @@ export * from "./services/selectionService"; export type { IShellService, ToolbarItemDefinition, SidePaneDefinition, CentralContentDefinition } from "./services/shellService"; export { ShellServiceIdentity } from "./services/shellService"; export * from "./inspector"; +export { StartInspectable, type InspectableToken, type InspectableOptions } from "./inspectable"; +export type { IInspectableCommandRegistry, InspectableCommandDescriptor, InspectableCommandArg } from "./services/cli/inspectableCommandRegistry"; +export { InspectableCommandRegistryIdentity } from "./services/cli/inspectableCommandRegistry"; export { ConvertOptions, Inspector } from "./legacy/inspector"; export { AttachDebugLayer, DetachDebugLayer } from "./legacy/debugLayer"; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts new file mode 100644 index 000000000000..8e27e80e751c --- /dev/null +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -0,0 +1,222 @@ +import { type IDisposable, type Nullable } from "core/index"; +import { Logger } from "core/Misc/logger"; +import { Observable } from "core/Misc/observable"; +import { type Scene } from "core/scene"; +import { type WeaklyTypedServiceDefinition, ServiceContainer } from "./modularity/serviceContainer"; +import { type ServiceDefinition } from "./modularity/serviceDefinition"; +import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; +import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; +import { PerfTraceCommandServiceDefinition } from "./services/cli/perfTraceCommandService"; +import { ScreenshotCommandServiceDefinition } from "./services/cli/screenshotCommandService"; +import { ShaderCommandServiceDefinition } from "./services/cli/shaderCommandService"; +import { StatsCommandServiceDefinition } from "./services/cli/statsCommandService"; +import { type ISceneContext, SceneContextIdentity } from "./services/sceneContext"; + +const DefaultPort = 4400; + +/** + * Options for making a scene inspectable via the Inspector CLI. + */ +export type InspectableOptions = { + /** + * WebSocket port for the bridge's browser port. Defaults to 4400. + */ + port?: number; + + /** + * Session display name reported to the bridge. Defaults to `document.title`. + */ + name?: string; + + /** + * Additional service definitions to register with the inspectable container. + * These are added in a separate call from the built-in services and are removed + * when the returned token is disposed. + */ + serviceDefinitions?: readonly WeaklyTypedServiceDefinition[]; +}; + +/** + * A token returned by {@link StartInspectable} that can be disposed to disconnect + * the scene from the Inspector CLI bridge. + */ +export type InspectableToken = IDisposable & { + /** + * Whether this token has been disposed. + */ + readonly isDisposed: boolean; +}; + +/** + * @internal + * An internal token that also exposes the underlying ServiceContainer, + * allowing ShowInspector to use it as a parent container. + */ +export type InternalInspectableToken = InspectableToken & { + /** + * The ServiceContainer backing this inspectable session. + */ + readonly serviceContainer: ServiceContainer; +}; + +// Track shared state per scene: the service container, ref count, and teardown logic. +type InspectableState = { + refCount: number; + serviceContainer: ServiceContainer; + sceneDisposeObserver: { remove: () => void }; + fullyDispose: () => void; + /** Resolves when the built-in services have been initialized. Rejects if initialization fails. */ + readyPromise: Promise; +}; + +const InspectableStates = new Map(); + +/** + * @internal + * Internal implementation that returns an {@link InternalInspectableToken} with access + * to the underlying ServiceContainer. Used by ShowInspector to set up a parent container relationship. + */ +export function _StartInspectable(scene: Scene, options?: Partial): InternalInspectableToken { + let state = InspectableStates.get(scene); + + if (!state) { + const port = options?.port ?? DefaultPort; + const name = options?.name ?? (typeof document !== "undefined" ? document.title : "Babylon.js Scene"); + + const serviceContainer = new ServiceContainer("InspectableContainer"); + + const fullyDispose = () => { + InspectableStates.delete(scene); + serviceContainer.dispose(); + sceneDisposeObserver.remove(); + }; + + // Initialize the service container asynchronously. + const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { + friendlyName: "Inspectable Scene Context", + produces: [SceneContextIdentity], + factory: () => ({ + currentScene: scene, + currentSceneObservable: new Observable>(), + }), + }; + + const readyPromise = (async () => { + await serviceContainer.addServicesAsync( + sceneContextServiceDefinition, + MakeInspectableBridgeServiceDefinition({ + port, + name, + }), + EntityQueryServiceDefinition, + ScreenshotCommandServiceDefinition, + ShaderCommandServiceDefinition, + StatsCommandServiceDefinition, + PerfTraceCommandServiceDefinition + ); + })(); + + state = { + refCount: 0, + serviceContainer, + sceneDisposeObserver: { remove: () => {} }, + fullyDispose, + readyPromise, + }; + + const capturedState = state; + + InspectableStates.set(scene, state); + + // Auto-dispose when the scene is disposed. + const sceneDisposeObserver = scene.onDisposeObservable.addOnce(() => { + capturedState.refCount = 0; + capturedState.fullyDispose(); + }); + state.sceneDisposeObserver = sceneDisposeObserver; + + // Handle initialization failure (guard against already-disposed state). + void (async () => { + try { + await readyPromise; + } catch (error: unknown) { + if (InspectableStates.has(scene)) { + Logger.Error(`Failed to initialize Inspectable: ${error}`); + capturedState.refCount = 0; + capturedState.fullyDispose(); + } + } + })(); + } + + state.refCount++; + const { serviceContainer } = state; + const owningState = state; + + // If additional service definitions were provided, add them in a separate call + // so they can be independently removed when this token is disposed. + let extraServicesDisposable: IDisposable | undefined; + const extraAbortController = new AbortController(); + const extraServiceDefinitions = options?.serviceDefinitions; + if (extraServiceDefinitions && extraServiceDefinitions.length > 0) { + // Wait for the built-in services to be ready, then add the extra ones. + void (async () => { + try { + await owningState.readyPromise; + extraServicesDisposable = await serviceContainer.addServicesAsync(...extraServiceDefinitions, extraAbortController.signal); + } catch (error: unknown) { + if (!extraAbortController.signal.aborted) { + Logger.Error(`Failed to add extra inspectable services: ${error}`); + } + } + })(); + } + + let disposed = false; + const token: InternalInspectableToken = { + get isDisposed() { + return disposed; + }, + get serviceContainer() { + return serviceContainer; + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + + // Abort any in-flight extra service initialization and remove already-added extra services. + extraAbortController.abort(); + extraServicesDisposable?.dispose(); + + owningState.refCount--; + if (owningState.refCount <= 0) { + owningState.fullyDispose(); + } + }, + }; + + return token; +} + +/** + * Makes a scene inspectable by connecting it to the Inspector CLI bridge. + * This creates a headless {@link ServiceContainer} (no UI) and registers the + * {@link InspectableBridgeService} which opens a WebSocket to the bridge and + * exposes a command registry for CLI-invocable commands. + * + * Multiple callers may call this for the same scene. Each returned token is + * ref-counted — the underlying connection is only torn down when all tokens + * have been disposed. Additional {@link InspectableOptions.serviceDefinitions} + * passed by each caller are added to the shared container and removed when + * that caller's token is disposed. + * + * @param scene The scene to make inspectable. + * @param options Optional configuration. + * @returns An {@link InspectableToken} that can be disposed to disconnect. + * @experimental + */ +export function StartInspectable(scene: Scene, options?: Partial): InspectableToken { + return _StartInspectable(scene, options); +} diff --git a/packages/dev/inspector-v2/src/inspector.tsx b/packages/dev/inspector-v2/src/inspector.tsx index 0986d2d50b88..c8a05101a21b 100644 --- a/packages/dev/inspector-v2/src/inspector.tsx +++ b/packages/dev/inspector-v2/src/inspector.tsx @@ -1,8 +1,7 @@ -import { type IDisposable, type IReadonlyObservable, type Nullable, type Scene } from "core/index"; +import { type IDisposable, type IReadonlyObservable, type Scene } from "core/index"; import { type WeaklyTypedServiceDefinition } from "./modularity/serviceContainer"; import { type ServiceDefinition } from "./modularity/serviceDefinition"; import { type ModularToolOptions, MakeModularTool } from "./modularTool"; -import { type ISceneContext, SceneContextIdentity } from "./services/sceneContext"; import { type IShellService, ShellServiceIdentity } from "./services/shellService"; import { AsyncLock } from "core/Misc/asyncLock"; @@ -10,7 +9,9 @@ import { Logger } from "core/Misc/logger"; import { Observable } from "core/Misc/observable"; import { useEffect, useRef } from "react"; import { DefaultInspectorExtensionFeed } from "./extensibility/defaultInspectorExtensionFeed"; +import { _StartInspectable } from "./inspectable"; import { LegacyInspectableObjectPropertiesServiceDefinition } from "./legacy/inspectableCustomPropertiesService"; +import { CliConnectionStatusServiceDefinition } from "./services/cliConnectionStatusService"; import { GizmoServiceDefinition } from "./services/gizmoService"; import { GizmoToolbarServiceDefinition } from "./services/gizmoToolbarService"; import { HighlightServiceDefinition } from "./services/highlightService"; @@ -228,6 +229,12 @@ export function ShowInspector(scene: Scene, options: Partial = // This array will contain all the default Inspector service definitions. const serviceDefinitions: WeaklyTypedServiceDefinition[] = []; + // Ensure the inspectable bridge is running for this scene. The inspector's + // ServiceContainer will use the inspectable container as a parent, inheriting + // services like ISceneContext and IInspectableCommandRegistry. + const inspectableToken = _StartInspectable(scene); + disposeActions.push(() => inspectableToken.dispose()); + // Create a container element for the inspector UI. // This element will become the root React node, so it must be a new empty node // since React will completely take over its contents. @@ -285,19 +292,6 @@ export function ShowInspector(scene: Scene, options: Partial = parentElement.removeChild(containerElement); }); - // This service exposes the scene that was passed into Inspector through ISceneContext, which is used by other services that may be used in other contexts outside of Inspector. - const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { - friendlyName: "Inspector Scene Context", - produces: [SceneContextIdentity], - factory: () => { - return { - currentScene: scene, - currentSceneObservable: new Observable>(), - }; - }, - }; - serviceDefinitions.push(sceneContextServiceDefinition); - if (options.autoResizeEngine) { const observer = scene.onBeforeRenderObservable.add(() => scene.getEngine().resize()); disposeActions.push(() => observer.remove()); @@ -392,6 +386,9 @@ export function ShowInspector(scene: Scene, options: Partial = // Adds entry points for user feedback on Inspector v2 (probably eventually will be removed). UserFeedbackServiceDefinition, + // Shows CLI bridge connection status in the toolbar. + CliConnectionStatusServiceDefinition, + // Adds always present "mini stats" (like fps) to the toolbar, etc. MiniStatsServiceDefinition, @@ -402,6 +399,7 @@ export function ShowInspector(scene: Scene, options: Partial = const modularTool = MakeModularTool({ namespace: "Inspector", containerElement, + parentContainer: inspectableToken.serviceContainer, serviceDefinitions: [ // Default Inspector services. ...serviceDefinitions, diff --git a/packages/dev/inspector-v2/src/misc/defaultPerfStrategies.ts b/packages/dev/inspector-v2/src/misc/defaultPerfStrategies.ts new file mode 100644 index 000000000000..c906405fddcf --- /dev/null +++ b/packages/dev/inspector-v2/src/misc/defaultPerfStrategies.ts @@ -0,0 +1,43 @@ +import { PerfCollectionStrategy } from "core/Misc/PerformanceViewer/performanceViewerCollectionStrategies"; +import { type PerformanceViewerCollector } from "core/Misc/PerformanceViewer/performanceViewerCollector"; + +/** + * The strategy parameter type accepted by {@link PerformanceViewerCollector.addCollectionStrategies}. + */ +export type PerfStrategyParameter = Parameters[number]; + +/** + * Performance metadata categories for grouping strategies. + */ +export const enum PerfMetadataCategory { + Count = "Count", + FrameSteps = "Frame Steps Duration", +} + +/** + * Default list of performance collection strategies used by the performance viewer and CLI perf trace. + */ +export const DefaultPerfStrategies: readonly PerfStrategyParameter[] = [ + { strategyCallback: PerfCollectionStrategy.FpsStrategy() }, + { strategyCallback: PerfCollectionStrategy.TotalMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveIndicesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveBonesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveParticlesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.DrawCallsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalLightsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalVerticesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalMaterialsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalTexturesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.AbsoluteFpsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.MeshesSelectionStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.RenderTargetsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ParticlesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.SpritesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.AnimationsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.PhysicsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.RenderStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.FrameTotalStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.InterFrameStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.GpuFrameTimeStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, +]; diff --git a/packages/dev/inspector-v2/src/modularTool.tsx b/packages/dev/inspector-v2/src/modularTool.tsx index b8e312a66829..e24f8a9c5e16 100644 --- a/packages/dev/inspector-v2/src/modularTool.tsx +++ b/packages/dev/inspector-v2/src/modularTool.tsx @@ -119,6 +119,12 @@ export type ModularToolOptions = { * The extension feeds that provide optional extensions the user can install. */ extensionFeeds?: readonly IExtensionFeed[]; + + /** + * An optional parent ServiceContainer. Dependencies not found in the tool's own container + * will be resolved from this parent. + */ + parentContainer?: ServiceContainer; } & ShellServiceOptions; /** @@ -127,7 +133,7 @@ export type ModularToolOptions = { * @returns A token that can be used to dispose of the tool. */ export function MakeModularTool(options: ModularToolOptions): IDisposable { - const { namespace, containerElement, serviceDefinitions, themeMode, showThemeSelector = true, extensionFeeds = [] } = options; + const { namespace, containerElement, serviceDefinitions, themeMode, showThemeSelector = true, extensionFeeds = [], parentContainer } = options; // Create the settings store immediately as it will be exposed to services and through React context. const settingsStore = new SettingsStore(namespace); @@ -160,7 +166,7 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable { // This is the main async initialization. useEffect(() => { const initializeExtensionManagerAsync = async () => { - const serviceContainer = new ServiceContainer("ModularToolContainer"); + const serviceContainer = new ServiceContainer("ModularToolContainer", parentContainer); // Expose the settings store as a service so other services can read/write settings. await serviceContainer.addServiceAsync<[ISettingsStore], []>({ diff --git a/packages/dev/inspector-v2/src/modularity/serviceContainer.ts b/packages/dev/inspector-v2/src/modularity/serviceContainer.ts index 543a2e03745f..ff7a944a966d 100644 --- a/packages/dev/inspector-v2/src/modularity/serviceContainer.ts +++ b/packages/dev/inspector-v2/src/modularity/serviceContainer.ts @@ -40,8 +40,19 @@ export class ServiceContainer implements IDisposable { private readonly _serviceDefinitions = new Map(); private readonly _serviceDependents = new Map>(); private readonly _serviceInstances = new Map & Partial) | void>(); + private readonly _children = new Set(); - public constructor(private readonly _friendlyName: string) {} + /** + * Creates a new ServiceContainer. + * @param _friendlyName A human-readable name for debugging. + * @param _parent An optional parent container. Dependencies not found locally will be resolved from the parent. + */ + public constructor( + private readonly _friendlyName: string, + private readonly _parent?: ServiceContainer + ) { + _parent?._children.add(this); + } /** * Adds a set of service definitions in the service container. @@ -115,28 +126,61 @@ export class ServiceContainer implements IDisposable { this._serviceDefinitions.set(contract, service); }); - const dependencies = - service.consumes?.map((dependency) => { - const dependencyDefinition = this._serviceDefinitions.get(dependency); - if (!dependencyDefinition) { - throw new Error(`Service '${dependency.toString()}' has not been registered in the '${this._friendlyName}' container.`); - } + const dependencies = service.consumes?.map((contract) => this._resolveDependency(contract, service)) ?? []; - let dependentDefinitions = this._serviceDependents.get(dependencyDefinition); - if (!dependentDefinitions) { - this._serviceDependents.set(dependencyDefinition, (dependentDefinitions = new Set())); - } - dependentDefinitions.add(service); + this._serviceInstances.set(service, await service.factory(...dependencies, abortSignal)); + } - const dependencyInstance = this._serviceInstances.get(dependencyDefinition); - if (!dependencyInstance) { - throw new Error(`Service '${dependency.toString()}' has not been instantiated in the '${this._friendlyName}' container.`); - } + /** + * Resolves a dependency by contract identity for a consuming service. + * Checks local services first, then walks up the parent chain. + * Registers the consumer as a dependent in whichever container owns the dependency. + * @param contract The contract identity to resolve. + * @param consumer The service definition that consumes this dependency. + * @returns The resolved service instance. + */ + private _resolveDependency(contract: symbol, consumer: WeaklyTypedServiceDefinition): IService & Partial { + const definition = this._serviceDefinitions.get(contract); + if (definition) { + let dependentDefinitions = this._serviceDependents.get(definition); + if (!dependentDefinitions) { + this._serviceDependents.set(definition, (dependentDefinitions = new Set())); + } + dependentDefinitions.add(consumer); - return dependencyInstance; - }) ?? []; + const instance = this._serviceInstances.get(definition); + if (!instance) { + throw new Error(`Service '${contract.toString()}' has not been instantiated in the '${this._friendlyName}' container.`); + } + return instance; + } - this._serviceInstances.set(service, await service.factory(...dependencies, abortSignal)); + if (this._parent) { + return this._parent._resolveDependency(contract, consumer); + } + + throw new Error(`Service '${contract.toString()}' has not been registered in the '${this._friendlyName}' container.`); + } + + /** + * Removes a consumer from the dependent set for a given contract, checking locally first then the parent chain. + * @param contract The contract identity. + * @param consumer The service definition to remove as a dependent. + */ + private _removeDependentFromChain(contract: symbol, consumer: WeaklyTypedServiceDefinition): void { + const definition = this._serviceDefinitions.get(contract); + if (definition) { + const dependentDefinitions = this._serviceDependents.get(definition); + if (dependentDefinitions) { + dependentDefinitions.delete(consumer); + if (dependentDefinitions.size === 0) { + this._serviceDependents.delete(definition); + } + } + return; + } + + this._parent?._removeDependentFromChain(contract, consumer); } private _removeService(service: WeaklyTypedServiceDefinition) { @@ -145,7 +189,7 @@ export class ServiceContainer implements IDisposable { } const serviceDependents = this._serviceDependents.get(service); - if (serviceDependents) { + if (serviceDependents && serviceDependents.size > 0) { throw new Error( `Service '${service.friendlyName}' has dependents: ${Array.from(serviceDependents) .map((dependent) => dependent.friendlyName) @@ -162,28 +206,26 @@ export class ServiceContainer implements IDisposable { this._serviceDefinitions.delete(contract); }); - service.consumes?.forEach((dependency) => { - const dependencyDefinition = this._serviceDefinitions.get(dependency); - if (dependencyDefinition) { - const dependentDefinitions = this._serviceDependents.get(dependencyDefinition); - if (dependentDefinitions) { - dependentDefinitions.delete(service); - if (dependentDefinitions.size === 0) { - this._serviceDependents.delete(dependencyDefinition); - } - } - } + // Remove this service as a dependent from each of its consumed dependencies (local or in parent chain). + service.consumes?.forEach((contract) => { + this._removeDependentFromChain(contract, service); }); } /** * Disposes the service container and all contained services. + * Throws if this container is still a parent of any live child containers. */ public dispose() { + if (this._children.size > 0) { + throw new Error(`'${this._friendlyName}' container cannot be disposed because it has ${this._children.size} active child container(s).`); + } + Array.from(this._serviceInstances.keys()).reverse().forEach(this._removeService.bind(this)); this._serviceInstances.clear(); this._serviceDependents.clear(); this._serviceDefinitions.clear(); + this._parent?._children.delete(this); this._isDisposed = true; } } diff --git a/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts b/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts new file mode 100644 index 000000000000..49693ccadf56 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts @@ -0,0 +1,22 @@ +import { type IReadonlyObservable } from "core/index"; +import { type IService } from "../../modularity/serviceDefinition"; + +/** + * The service identity for the CLI connection status. + */ +export const CliConnectionStatusIdentity = Symbol("CliConnectionStatus"); + +/** + * Provides the connection status of the Inspector CLI bridge. + */ +export interface ICliConnectionStatus extends IService { + /** + * Whether the bridge WebSocket is currently connected. + */ + readonly isConnected: boolean; + + /** + * Observable that fires when the connection status changes. + */ + readonly onConnectionStatusChanged: IReadonlyObservable; +} diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts new file mode 100644 index 000000000000..86c7e49c64d4 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -0,0 +1,309 @@ +import { + type AbstractMesh, + type Animation, + type AnimationGroup, + type BaseTexture, + type Camera, + type EffectLayer, + type FrameGraph, + type Geometry, + type IDisposable, + type IParticleSystem, + type ISpriteManager, + type Light, + type Material, + type MorphTargetManager, + type MultiMaterial, + type PostProcess, + type PostProcessRenderPipeline, + type Scene, + type Skeleton, + type Sound, + type TransformNode, +} from "core/index"; +import { UniqueIdGenerator } from "core/Misc/uniqueIdGenerator"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, type InspectableCommandDescriptor, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +const UniqueIdArg = { + name: "uniqueId", + description: "The uniqueId of the entity to query. Omit to list all entities of this type.", + required: false, +} as const; + +const SyntheticUniqueIds = new WeakMap(); + +function GetEntityId(entity: object): number { + if ("uniqueId" in entity && typeof entity.uniqueId === "number") { + return entity.uniqueId; + } + + let id = SyntheticUniqueIds.get(entity); + if (!id) { + SyntheticUniqueIds.set(entity, (id = UniqueIdGenerator.UniqueId)); + } + return id; +} + +interface IEntitySummary { + /** The unique id. */ + uniqueId: number; + /** The entity name, if available. */ + name?: string; + /** The class name from getClassName(), if available. */ + className?: string; + /** The parent's uniqueId, if the entity is hierarchical. */ + parentId?: number; +} + +interface IEntityCollection { + /** The command id. */ + id: string; + /** The command description. */ + description: string; + /** Accessor for the entity array from the scene. */ + getEntities: (scene: Scene) => T[] | undefined; + /** Gets the uniqueId from an entity (uses synthetic ids for entities without a native uniqueId). */ + getUniqueId: (entity: T) => number; + /** Builds a summary for listing. */ + getSummary: (entity: T) => IEntitySummary; + /** Serializes a single entity to a plain object. If absent, querying by id returns the summary. */ + serialize?: (entity: T) => unknown; +} + +function NodeSummary(entity: { uniqueId: number; name: string; getClassName(): string; parent?: { uniqueId: number } | null }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + className: entity.getClassName(), + parentId: entity.parent?.uniqueId, + }; +} + +function NamedSummary(entity: { uniqueId: number; name: string; getClassName(): string }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + className: entity.getClassName(), + }; +} + +function MinimalSummary(entity: { uniqueId: number; name?: string }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + }; +} + +function MakeQueryCommand(collection: IEntityCollection, sceneContext: ISceneContext): InspectableCommandDescriptor { + return { + id: collection.id, + description: collection.description, + args: [UniqueIdArg], + executeAsync: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const entities = collection.getEntities(scene); + if (!entities) { + return JSON.stringify([], null, 2); + } + + if (!args.uniqueId) { + return JSON.stringify( + entities.map((e) => collection.getSummary(e)), + null, + 2 + ); + } + + const id = parseInt(args.uniqueId, 10); + if (isNaN(id)) { + throw new Error("uniqueId must be a number."); + } + + const entity = entities.find((e) => collection.getUniqueId(e) === id); + if (!entity) { + throw new Error(`No ${collection.id.replace("query-", "")} found with uniqueId ${id}.`); + } + + return JSON.stringify(collection.serialize ? collection.serialize(entity) : collection.getSummary(entity), null, 2); + }, + }; +} + +/** + * Service that registers CLI commands for querying scene entities by uniqueId. + * When uniqueId is omitted, returns a summary list of all entities of that type. + */ +export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Entity Query Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + const collections = [ + { + id: "query-mesh", + description: "List meshes, or query a specific mesh by uniqueId.", + getEntities: (scene) => scene.meshes, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-light", + description: "List lights, or query a specific light by uniqueId.", + getEntities: (scene) => scene.lights, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-camera", + description: "List cameras, or query a specific camera by uniqueId.", + getEntities: (scene) => scene.cameras, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-transformNode", + description: "List transform nodes, or query a specific transform node by uniqueId.", + getEntities: (scene) => scene.transformNodes, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-material", + description: "List materials, or query a specific material by uniqueId.", + getEntities: (scene) => scene.materials, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-texture", + description: "List textures, or query a specific texture by uniqueId.", + getEntities: (scene) => scene.textures, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-skeleton", + description: "List skeletons, or query a specific skeleton by uniqueId.", + getEntities: (scene) => scene.skeletons, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-geometry", + description: "List geometries, or query a specific geometry by uniqueId.", + getEntities: (scene) => scene.geometries, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-animation", + description: "List animations, or query a specific animation by uniqueId.", + getEntities: (scene) => scene.animations, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-animationGroup", + description: "List animation groups, or query a specific animation group by uniqueId.", + getEntities: (scene) => scene.animationGroups, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-particleSystem", + description: "List particle systems, or query a specific particle system by uniqueId.", + getEntities: (scene) => scene.particleSystems, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(false), + } satisfies IEntityCollection, + { + id: "query-morphTargetManager", + description: "List morph target managers, or query a specific morph target manager by uniqueId.", + getEntities: (scene) => scene.morphTargetManagers, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-multiMaterial", + description: "List multi-materials, or query a specific multi-material by uniqueId.", + getEntities: (scene) => scene.multiMaterials, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-postProcess", + description: "List post-processes, or query a specific post-process by uniqueId.", + getEntities: (scene) => scene.postProcesses, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-frameGraph", + description: "List frame graphs, or query a specific frame graph by uniqueId.", + getEntities: (scene) => scene.frameGraphs, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + } satisfies IEntityCollection, + { + id: "query-effectLayer", + description: "List effect layers, or query a specific effect layer by uniqueId.", + getEntities: (scene) => scene.effectLayers, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize?.(), + } satisfies IEntityCollection, + { + id: "query-spriteManager", + description: "List sprite managers, or query a specific sprite manager by uniqueId.", + getEntities: (scene) => scene.spriteManagers, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(false), + } satisfies IEntityCollection, + { + id: "query-sound", + description: "List sounds in the main sound track, or query a specific sound by uniqueId.", + getEntities: (scene) => scene.mainSoundTrack?.soundCollection ?? [], + getUniqueId: (e) => GetEntityId(e), + getSummary: (e) => ({ uniqueId: GetEntityId(e), name: e.name, className: e.getClassName() }), + serialize: (e) => e.serialize(), + } satisfies IEntityCollection, + { + id: "query-renderingPipeline", + description: "List rendering pipelines, or query a specific rendering pipeline by uniqueId.", + getEntities: (scene) => scene.postProcessRenderPipelineManager?.supportedPipelines ?? [], + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + } satisfies IEntityCollection, + ]; + + const registrations: IDisposable[] = collections.map((col) => commandRegistry.addCommand(MakeQueryCommand(col as IEntityCollection, sceneContext))); + + return { + dispose: () => { + for (const reg of registrations) { + reg.dispose(); + } + }, + }; + }, +}; diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts new file mode 100644 index 000000000000..f8f2c5242682 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -0,0 +1,183 @@ +import { type IDisposable } from "core/index"; +import { Observable } from "core/Misc/observable"; +import { type BrowserRequest, type BrowserResponse, type CommandInfo } from "../../cli/protocol"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ICliConnectionStatus, CliConnectionStatusIdentity } from "./cliConnectionStatus"; +import { type IInspectableCommandRegistry, type InspectableCommandDescriptor, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +import { Logger } from "core/Misc/logger"; + +/** + * Options for the inspectable bridge service. + */ +export interface IInspectableBridgeServiceOptions { + /** + * The WebSocket port for the bridge's browser port. + */ + port: number; + + /** + * The session display name sent to the bridge. + */ + name: string; +} + +/** + * Creates the service definition for the InspectableBridgeService. + * @param options The options for connecting to the bridge. + * @returns A service definition that produces an IInspectableCommandRegistry. + */ +export function MakeInspectableBridgeServiceDefinition(options: IInspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry, ICliConnectionStatus], []> { + return { + friendlyName: "Inspectable Bridge Service", + produces: [InspectableCommandRegistryIdentity, CliConnectionStatusIdentity], + factory: () => { + const commands = new Map(); + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + let connected = false; + const onConnectionStatusChanged = new Observable(); + + function setConnected(value: boolean) { + if (connected !== value) { + connected = value; + onConnectionStatusChanged.notifyObservers(value); + } + } + + function sendToBridge(message: BrowserRequest) { + ws?.send(JSON.stringify(message)); + } + + function connect() { + if (disposed) { + return; + } + + try { + ws = new WebSocket(`ws://127.0.0.1:${options.port}`); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + setConnected(true); + sendToBridge({ type: "register", name: options.name }); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + void handleMessage(message); + } catch { + Logger.Warn("InspectableBridgeService: Failed to parse message from bridge."); + } + }; + + ws.onclose = () => { + ws = null; + setConnected(false); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after onerror, which handles reconnection. + }; + } + + function scheduleReconnect() { + if (disposed || reconnectTimer !== null) { + return; + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, 3000); + } + + async function handleMessage(message: BrowserResponse) { + switch (message.type) { + case "listCommands": { + const commandList: CommandInfo[] = Array.from(commands.values()).map((cmd) => ({ + id: cmd.id, + description: cmd.description, + args: cmd.args, + })); + sendToBridge({ + type: "commandListResponse", + requestId: message.requestId, + commands: commandList, + }); + break; + } + case "execCommand": { + const command = commands.get(message.commandId); + if (!command) { + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + error: `Unknown command: ${message.commandId}`, + }); + break; + } + try { + const result = await command.executeAsync(message.args); + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + result, + }); + } catch (error: unknown) { + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + error: String(error), + }); + } + break; + } + } + } + + // Initiate connection. + connect(); + + const registry: IInspectableCommandRegistry & ICliConnectionStatus & IDisposable = { + addCommand(descriptor: InspectableCommandDescriptor): IDisposable { + if (commands.has(descriptor.id)) { + throw new Error(`Command '${descriptor.id}' is already registered.`); + } + commands.set(descriptor.id, descriptor); + return { + dispose: () => { + commands.delete(descriptor.id); + }, + }; + }, + get isConnected() { + return connected; + }, + onConnectionStatusChanged, + dispose: () => { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + commands.clear(); + setConnected(false); + onConnectionStatusChanged.clear(); + if (ws) { + ws.onclose = null; + ws.close(); + ws = null; + } + }, + }; + + return registry; + }, + }; +} diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts new file mode 100644 index 000000000000..02afddf7e53a --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts @@ -0,0 +1,67 @@ +import { type IDisposable } from "core/index"; +import { type IService } from "../../modularity/serviceDefinition"; + +/** + * Describes an argument for an inspectable command. + */ +export type InspectableCommandArg = { + /** + * The name of the argument. + */ + name: string; + + /** + * A description of the argument. + */ + description: string; + + /** + * Whether the argument is required. + */ + required?: boolean; +}; + +/** + * Describes a command that can be invoked from the CLI. + */ +export type InspectableCommandDescriptor = { + /** + * A unique identifier for the command. + */ + id: string; + + /** + * A human-readable description of what the command does. + */ + description: string; + + /** + * The arguments that this command accepts. + */ + args?: InspectableCommandArg[]; + + /** + * Executes the command with the given arguments and returns a result string. + * @param args A map of argument names to their values. + * @returns A promise that resolves to the result string. + */ + executeAsync: (args: Record) => Promise; +}; + +/** + * The service identity for the inspectable command registry. + */ +export const InspectableCommandRegistryIdentity = Symbol("InspectableCommandRegistry"); + +/** + * A registry for commands that can be invoked from the Inspector CLI. + * @experimental + */ +export interface IInspectableCommandRegistry extends IService { + /** + * Registers a command that can be invoked from the Inspector CLI. + * @param descriptor The command descriptor. + * @returns A disposable token that unregisters the command when disposed. + */ + addCommand(descriptor: InspectableCommandDescriptor): IDisposable; +} diff --git a/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts b/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts new file mode 100644 index 000000000000..11f5e8acdabd --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts @@ -0,0 +1,85 @@ +import { PerformanceViewerCollector } from "core/Misc/PerformanceViewer/performanceViewerCollector"; +import { DefaultPerfStrategies } from "../../misc/defaultPerfStrategies"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +import "core/Misc/PerformanceViewer/performanceViewerSceneExtension"; + +/** + * Service that registers CLI commands for performance tracing using the PerformanceViewerCollector. + * start-perf-trace begins collecting data, stop-perf-trace stops and returns the collected data as JSON. + */ +export const PerfTraceCommandServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Perf Trace Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + let perfCollector: PerformanceViewerCollector | undefined; + + const startReg = commandRegistry.addCommand({ + id: "start-perf-trace", + description: "Start collecting performance trace data.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + if (perfCollector?.isStarted) { + return "Performance trace is already running."; + } + + perfCollector = scene.getPerfCollector(); + perfCollector.stop(); + perfCollector.clear(false); + perfCollector.addCollectionStrategies(...DefaultPerfStrategies); + perfCollector.start(true); + + return "Performance trace started."; + }, + }); + + const stopReg = commandRegistry.addCommand({ + id: "stop-perf-trace", + description: "Stop collecting performance trace data and return the results as JSON.", + executeAsync: async () => { + if (!perfCollector || !perfCollector.isStarted) { + throw new Error("Performance trace is not running. Run start-perf-trace first."); + } + + perfCollector.stop(); + + const datasets = perfCollector.datasets; + const ids = datasets.ids; + const rawData = datasets.data.subarray(0, datasets.data.itemLength); + const sliceSize = ids.length + PerformanceViewerCollector.SliceDataOffset; + + const samples: Record[] = []; + for (let i = 0; i < rawData.length; i += sliceSize) { + const timestamp = rawData[i]; + const sample: Record = { timestamp }; + for (let j = 0; j < ids.length; j++) { + sample[ids[j]] = rawData[i + PerformanceViewerCollector.SliceDataOffset + j]; + } + samples.push(sample); + } + + perfCollector.clear(false); + perfCollector = undefined; + + return JSON.stringify({ strategies: ids, sampleCount: samples.length, samples }, null, 2); + }, + }); + + return { + dispose: () => { + startReg.dispose(); + stopReg.dispose(); + if (perfCollector?.isStarted) { + perfCollector.stop(); + } + perfCollector = undefined; + }, + }; + }, +}; diff --git a/packages/dev/inspector-v2/src/services/cli/screenshotCommandService.ts b/packages/dev/inspector-v2/src/services/cli/screenshotCommandService.ts new file mode 100644 index 000000000000..fd19e0d11279 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/screenshotCommandService.ts @@ -0,0 +1,104 @@ +import { FrameGraphUtils } from "core/FrameGraph/frameGraphUtils"; +import { CreateScreenshotUsingRenderTargetAsync } from "core/Misc/screenshotTools"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +/** + * Service that registers a CLI command for capturing a screenshot of the scene. + * Returns the image as a base64 data string, suitable for consumption by AI agents. + */ +export const ScreenshotCommandServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Screenshot Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + const registration = commandRegistry.addCommand({ + id: "take-screenshot", + description: "Capture a screenshot of the scene. Returns base64-encoded PNG data.", + args: [ + { + name: "cameraUniqueId", + description: "The uniqueId of the camera to use. Defaults to the active camera.", + required: false, + }, + { + name: "width", + description: "Screenshot width in pixels. When set, uses custom size mode.", + required: false, + }, + { + name: "height", + description: "Screenshot height in pixels. When set, uses custom size mode.", + required: false, + }, + { + name: "precision", + description: "Resolution multiplier (e.g. 2 for double resolution). Defaults to 1.", + required: false, + }, + ], + executeAsync: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const engine = scene.getEngine(); + + // Resolve camera: explicit uniqueId, or active/frame-graph camera. + let camera; + if (args.cameraUniqueId) { + const cameraId = parseInt(args.cameraUniqueId, 10); + if (isNaN(cameraId)) { + throw new Error("cameraUniqueId must be a number."); + } + camera = scene.cameras.find((c) => c.uniqueId === cameraId); + if (!camera) { + throw new Error(`No camera found with uniqueId ${cameraId}.`); + } + } else { + camera = scene.frameGraph ? FrameGraphUtils.FindMainCamera(scene.frameGraph) : scene.activeCamera; + } + + if (!camera) { + throw new Error("No camera available for screenshot."); + } + + const precision = args.precision !== undefined ? Number(args.precision) : 1; + if (!Number.isFinite(precision) || precision <= 0) { + throw new Error("precision must be a finite number greater than 0."); + } + + let width: number | undefined; + if (args.width !== undefined) { + width = Number(args.width); + if (!Number.isFinite(width) || width <= 0 || !Number.isInteger(width)) { + throw new Error("width must be a finite positive integer."); + } + } + + let height: number | undefined; + if (args.height !== undefined) { + height = Number(args.height); + if (!Number.isFinite(height) || height <= 0 || !Number.isInteger(height)) { + throw new Error("height must be a finite positive integer."); + } + } + const screenshotSize = width !== undefined && height !== undefined ? { width, height, precision } : { precision }; + + // Omit fileName to get data URL back without triggering a download. + const dataUrl = await CreateScreenshotUsingRenderTargetAsync(engine, camera, screenshotSize, "image/png"); + + // Strip the data URI prefix to return raw base64, which is what AI agent APIs expect. + const commaIndex = dataUrl.indexOf(","); + return commaIndex !== -1 ? dataUrl.substring(commaIndex + 1) : dataUrl; + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/dev/inspector-v2/src/services/cli/shaderCommandService.ts b/packages/dev/inspector-v2/src/services/cli/shaderCommandService.ts new file mode 100644 index 000000000000..53190dcabef9 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/shaderCommandService.ts @@ -0,0 +1,84 @@ +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +/** + * Service that registers a CLI command for retrieving compiled shader code from a material. + */ +export const ShaderCommandServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Shader Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + const registration = commandRegistry.addCommand({ + id: "get-shader-code", + description: "Get the shader code for a material by uniqueId.", + args: [ + { + name: "uniqueId", + description: "The uniqueId of the material.", + required: true, + }, + { + name: "variant", + description: "Which shader variant to return: compiled (default), raw, or beforeMigration.", + required: false, + }, + ], + executeAsync: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const id = parseInt(args.uniqueId, 10); + if (isNaN(id)) { + throw new Error("uniqueId must be a number."); + } + + const material = scene.materials.find((m) => m.uniqueId === id); + if (!material) { + throw new Error(`No material found with uniqueId ${id}.`); + } + + const effect = material.getEffect(); + if (!effect) { + throw new Error(`Material "${material.name}" has no effect. It may not have been rendered yet.`); + } + + if (!effect.isReady()) { + throw new Error(`Material "${material.name}" effect is not ready. Wait for it to be rendered.`); + } + + const variant = args.variant ?? "compiled"; + + let vertexShader: string; + let fragmentShader: string; + + switch (variant) { + case "compiled": + vertexShader = effect.vertexSourceCode; + fragmentShader = effect.fragmentSourceCode; + break; + case "raw": + vertexShader = effect.rawVertexSourceCode; + fragmentShader = effect.rawFragmentSourceCode; + break; + case "beforeMigration": + vertexShader = effect.vertexSourceCodeBeforeMigration; + fragmentShader = effect.fragmentSourceCodeBeforeMigration; + break; + default: + throw new Error(`Unknown variant "${variant}". Use: compiled, raw, or beforeMigration.`); + } + + return JSON.stringify({ vertexShader, fragmentShader }, null, 2); + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; diff --git a/packages/dev/inspector-v2/src/services/cli/statsCommandService.ts b/packages/dev/inspector-v2/src/services/cli/statsCommandService.ts new file mode 100644 index 000000000000..a5fd73361617 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/statsCommandService.ts @@ -0,0 +1,203 @@ +import { EngineInstrumentation } from "core/Instrumentation/engineInstrumentation"; +import { SceneInstrumentation } from "core/Instrumentation/sceneInstrumentation"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +// Side-effect imports for engine query support (needed by EngineInstrumentation). +import "core/Engines/AbstractEngine/abstractEngine.timeQuery"; +import "core/Engines/Extensions/engine.query"; + +/** + * Service that registers CLI commands for querying scene and engine statistics. + */ +export const StatsCommandServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Stats Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + let sceneInstrumentation: SceneInstrumentation | undefined; + let engineInstrumentation: EngineInstrumentation | undefined; + + function disposeInstrumentation() { + sceneInstrumentation?.dispose(); + sceneInstrumentation = undefined; + engineInstrumentation?.dispose(); + engineInstrumentation = undefined; + } + + const startPerfReg = commandRegistry.addCommand({ + id: "start-perf-instrumentation", + description: "Start scene and engine performance instrumentation for frame stats collection.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + // Dispose any stale instrumentation (e.g. scene changed). + if (sceneInstrumentation && sceneInstrumentation.scene !== scene) { + disposeInstrumentation(); + } + + if (sceneInstrumentation) { + return "Performance instrumentation is already running."; + } + + sceneInstrumentation = new SceneInstrumentation(scene); + sceneInstrumentation.captureActiveMeshesEvaluationTime = true; + sceneInstrumentation.captureRenderTargetsRenderTime = true; + sceneInstrumentation.captureFrameTime = true; + sceneInstrumentation.captureRenderTime = true; + sceneInstrumentation.captureInterFrameTime = true; + sceneInstrumentation.captureParticlesRenderTime = true; + sceneInstrumentation.captureSpritesRenderTime = true; + sceneInstrumentation.capturePhysicsTime = true; + sceneInstrumentation.captureAnimationsTime = true; + + engineInstrumentation = new EngineInstrumentation(scene.getEngine()); + engineInstrumentation.captureGPUFrameTime = true; + + return "Performance instrumentation started."; + }, + }); + + const stopPerfReg = commandRegistry.addCommand({ + id: "stop-perf-instrumentation", + description: "Stop scene and engine performance instrumentation.", + executeAsync: async () => { + if (!sceneInstrumentation && !engineInstrumentation) { + return "Performance instrumentation is not running."; + } + + disposeInstrumentation(); + return "Performance instrumentation stopped."; + }, + }); + + const countStatsReg = commandRegistry.addCommand({ + id: "get-count-stats", + description: "Get scene entity counts (meshes, lights, vertices, draw calls, etc.).", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + let activeMeshesCount = scene.getActiveMeshes().length; + for (const objectRenderer of scene.objectRenderers) { + activeMeshesCount += objectRenderer.getActiveMeshes().length; + } + + const activeIndices = scene.getActiveIndices(); + + return JSON.stringify( + { + totalMeshes: scene.meshes.length, + activeMeshes: activeMeshesCount, + activeIndices, + activeFaces: Math.floor(activeIndices / 3), + activeBones: scene.getActiveBones(), + activeParticles: scene.getActiveParticles(), + drawCalls: scene.getEngine()._drawCalls.current, + totalLights: scene.lights.length, + totalVertices: scene.getTotalVertices(), + totalMaterials: scene.materials.length, + totalTextures: scene.textures.length, + }, + null, + 2 + ); + }, + }); + + const frameStatsReg = commandRegistry.addCommand({ + id: "get-frame-stats", + description: "Get frame timing statistics. Requires start-perf-instrumentation to be run first.", + executeAsync: async () => { + if (!sceneInstrumentation || !engineInstrumentation) { + throw new Error("Performance instrumentation is not running. Run start-perf-instrumentation first."); + } + + const si = sceneInstrumentation; + const ei = engineInstrumentation; + + const round = (v: number) => Math.round(v * 100) / 100; + + return JSON.stringify( + { + absoluteFPS: Math.floor(1000.0 / si.frameTimeCounter.lastSecAverage), + meshesSelectionMs: round(si.activeMeshesEvaluationTimeCounter.lastSecAverage), + renderTargetsMs: round(si.renderTargetsRenderTimeCounter.lastSecAverage), + particlesMs: round(si.particlesRenderTimeCounter.lastSecAverage), + spritesMs: round(si.spritesRenderTimeCounter.lastSecAverage), + animationsMs: round(si.animationsTimeCounter.lastSecAverage), + physicsMs: round(si.physicsTimeCounter.lastSecAverage), + renderMs: round(si.renderTimeCounter.lastSecAverage), + frameMs: round(si.frameTimeCounter.lastSecAverage), + interFrameMs: round(si.interFrameTimeCounter.lastSecAverage), + gpuFrameMs: round(ei.gpuFrameTimeCounter.lastSecAverage * 0.000001), + gpuFrameAverageMs: round(ei.gpuFrameTimeCounter.average * 0.000001), + }, + null, + 2 + ); + }, + }); + + const systemStatsReg = commandRegistry.addCommand({ + id: "get-system-stats", + description: "Get engine capabilities and system information.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const engine = scene.getEngine(); + const caps = engine.getCaps(); + + return JSON.stringify( + { + resolution: `${engine.getRenderWidth()} x ${engine.getRenderHeight()}`, + hardwareScalingLevel: engine.getHardwareScalingLevel(), + engine: engine.description, + driver: engine.extractDriverInfo(), + capabilities: { + stdDerivatives: caps.standardDerivatives, + compressedTextures: caps.s3tc !== undefined, + hardwareInstances: caps.instancedArrays, + textureFloat: caps.textureFloat, + textureHalfFloat: caps.textureHalfFloat, + renderToTextureFloat: caps.textureFloatRender, + renderToTextureHalfFloat: caps.textureHalfFloatRender, + indices32Bit: caps.uintIndices, + fragmentDepth: caps.fragmentDepthSupported, + highPrecisionShaders: caps.highPrecisionShaderSupported, + drawBuffers: caps.drawBuffersExtension, + vertexArrayObject: caps.vertexArrayObject, + timerQuery: caps.timerQuery !== undefined, + stencil: engine.isStencilEnable, + parallelShaderCompilation: caps.parallelShaderCompile != null, + maxTexturesUnits: caps.maxTexturesImageUnits, + maxTexturesSize: caps.maxTextureSize, + maxAnisotropy: caps.maxAnisotropy, + }, + }, + null, + 2 + ); + }, + }); + + return { + dispose: () => { + startPerfReg.dispose(); + stopPerfReg.dispose(); + countStatsReg.dispose(); + frameStatsReg.dispose(); + systemStatsReg.dispose(); + disposeInstrumentation(); + }, + }; + }, +}; diff --git a/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx b/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx new file mode 100644 index 000000000000..be203e0a1564 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx @@ -0,0 +1,59 @@ +import { Button, tokens } from "@fluentui/react-components"; +import { PlugConnectedRegular, PlugDisconnectedRegular } from "@fluentui/react-icons"; +import { useEffect, useRef } from "react"; + +import { useToast } from "shared-ui-components/fluent/primitives/toast"; +import { Tooltip } from "shared-ui-components/fluent/primitives/tooltip"; +import { useObservableState } from "../hooks/observableHooks"; +import { type ServiceDefinition } from "../modularity/serviceDefinition"; +import { type ICliConnectionStatus, CliConnectionStatusIdentity } from "./cli/cliConnectionStatus"; +import { DefaultToolbarItemOrder } from "./defaultToolbarMetadata"; +import { type IShellService, ShellServiceIdentity } from "./shellService"; + +export const CliConnectionStatusServiceDefinition: ServiceDefinition<[], [IShellService, ICliConnectionStatus]> = { + friendlyName: "CLI Connection Status", + consumes: [ShellServiceIdentity, CliConnectionStatusIdentity], + factory: (shellService, cliConnectionStatus) => { + shellService.addToolbarItem({ + key: "CLI Connection Status", + verticalLocation: "bottom", + horizontalLocation: "right", + teachingMoment: false, + order: DefaultToolbarItemOrder.CliStatus, + component: () => { + const isConnected = useObservableState(() => cliConnectionStatus.isConnected, cliConnectionStatus.onConnectionStatusChanged); + const { showToast } = useToast(); + const isFirstRender = useRef(true); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + if (isConnected) { + showToast("Inspector bridge connected.", { intent: "success" }); + } else { + showToast("Inspector bridge disconnected.", { intent: "warning" }); + } + }, [isConnected, showToast]); + + // Using raw Fluent Button to pass color directly to the icon. + return ( + +