From 676290faf74791fd8ecf3567205a4a5c5c3e6e34 Mon Sep 17 00:00:00 2001 From: Aiden Date: Tue, 19 May 2026 16:19:27 +1000 Subject: [PATCH] refactor --- README.md | 2 + src/App.tsx | 197 ++++++ src/components/DetailsPanel.tsx | 108 +++ src/components/NodeIcon.tsx | 23 + src/components/OutlinerRow.tsx | 77 +++ src/lib/remoteValues.ts | 151 +++++ src/main.tsx | 1132 +------------------------------ src/models/outlinerTree.ts | 92 +++ src/services/details.ts | 111 +++ src/services/detailsCache.ts | 108 +++ src/services/remoteControl.ts | 225 ++++++ src/services/unrealActors.ts | 138 ++++ src/types.ts | 50 ++ 13 files changed, 1283 insertions(+), 1131 deletions(-) create mode 100644 src/App.tsx create mode 100644 src/components/DetailsPanel.tsx create mode 100644 src/components/NodeIcon.tsx create mode 100644 src/components/OutlinerRow.tsx create mode 100644 src/lib/remoteValues.ts create mode 100644 src/models/outlinerTree.ts create mode 100644 src/services/details.ts create mode 100644 src/services/detailsCache.ts create mode 100644 src/services/remoteControl.ts create mode 100644 src/services/unrealActors.ts create mode 100644 src/types.ts diff --git a/README.md b/README.md index a95cb66..6ffe791 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ PUT /remote/object/property The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unreal for all readable properties exposed on that UObject. +Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background. + ## Run Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`. diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..429f459 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshCw, Search, Settings, SlidersHorizontal, X } from "lucide-react"; +import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types"; +import { DetailsPanel } from "./components/DetailsPanel"; +import { OutlinerRow } from "./components/OutlinerRow"; +import { extractObjectPath, extractReturnValue } from "./lib/remoteValues"; +import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree"; +import { normalizeDetails } from "./services/details"; +import { readCachedDetails, writeCachedDetails } from "./services/detailsCache"; +import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl"; +import { enrichActors, getAllLevelActors, normalizeActor } from "./services/unrealActors"; + +export function App() { + const [actors, setActors] = React.useState([]); + const [query, setQuery] = React.useState(""); + const [expanded, setExpanded] = React.useState>(new Set(["world"])); + const [selectedId, setSelectedId] = React.useState(null); + const [status, setStatus] = React.useState("Ready"); + const [transport, setTransport] = React.useState("HTTP"); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [details, setDetails] = React.useState(null); + const [detailsError, setDetailsError] = React.useState(null); + const [isDetailsLoading, setIsDetailsLoading] = React.useState(false); + + const tree = React.useMemo(() => buildTree(actors), [actors]); + const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]); + + const refresh = React.useCallback(async () => { + setIsLoading(true); + setError(null); + setStatus("Loading actors..."); + try { + const payload = await getAllLevelActors(); + const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) })); + const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index))); + setTransport(getActiveTransport()); + setActors(rows); + setExpanded((current) => new Set([...current, "world"])); + setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`); + } catch (reason) { + const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control"; + setError(message); + setStatus("Connection failed"); + } finally { + setIsLoading(false); + } + }, []); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + React.useEffect(() => { + let isCancelled = false; + + if (!selectedNode?.objectPath) { + setDetails(null); + setDetailsError(null); + setIsDetailsLoading(false); + return; + } + + const objectPath = selectedNode.objectPath; + const cachedDetails = readCachedDetails(objectPath); + if (cachedDetails) { + setDetails(cachedDetails); + setIsDetailsLoading(false); + } else { + setDetails(null); + setIsDetailsLoading(true); + } + setDetailsError(null); + + Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)]) + .then(([description, properties]) => { + if (!isCancelled) { + const freshDetails = normalizeDetails(description, properties); + writeCachedDetails(objectPath, freshDetails); + setDetails(freshDetails); + setTransport(getActiveTransport()); + } + }) + .catch((reason) => { + if (!isCancelled) { + setDetails(null); + setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties"); + } + }) + .finally(() => { + if (!isCancelled) { + setIsDetailsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [selectedNode?.objectPath]); + + const toggleNode = (node: TreeNode) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(node.id)) { + next.delete(node.id); + } else { + next.add(node.id); + } + return next; + }); + }; + + return ( +
+
+
+
+ + Outliner + +
+
+ + Levels +
+
+ + Layers +
+
+ +
+ + + + + + +
+ +
+ + + + Item Label + Type + Level + ID Name +
+ +
+
+ {error ? ( +
+ {status} + {error} + Remote Control is reachable, but Unreal rejected the object call. Check the exact error above. +
+ ) : ( + setSelectedId(node.id)} + /> + )} +
+ + +
+ +
+ {query ? `${visibleCount(tree)} actors total` : `${actors.length} actor${actors.length === 1 ? "" : "s"}`} + + {status} - {transport} + +
+
+
+ ); +} diff --git a/src/components/DetailsPanel.tsx b/src/components/DetailsPanel.tsx new file mode 100644 index 0000000..d72b530 --- /dev/null +++ b/src/components/DetailsPanel.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { ChevronDown, Search, Settings, SlidersHorizontal, X } from "lucide-react"; +import type { DetailProperty, ObjectDetails, TreeNode } from "../types"; +import { basename } from "../lib/remoteValues"; +import { formatDetailValue } from "../services/details"; +import { NodeIcon } from "./NodeIcon"; + +const emptyNode: TreeNode = { + id: "empty", + label: "No Selection", + type: "Object", + level: "", + idName: "", + kind: "actor", + children: [], +}; + +export function DetailsPanel({ + node, + details, + isLoading, + error, +}: { + node: TreeNode | null; + details: ObjectDetails | null; + isLoading: boolean; + error: string | null; +}) { + const [query, setQuery] = React.useState(""); + + const groupedProperties = React.useMemo(() => { + const lowerQuery = query.toLowerCase(); + const visibleProperties = (details?.properties ?? []).filter((property) => { + if (!lowerQuery) return true; + return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) => + field.toLowerCase().includes(lowerQuery), + ); + }); + + return visibleProperties.reduce>((groups, property) => { + const category = property.category || "General"; + groups[category] = groups[category] ?? []; + groups[category].push(property); + return groups; + }, {}); + }, [details, query]); + + return ( + + ); +} diff --git a/src/components/NodeIcon.tsx b/src/components/NodeIcon.tsx new file mode 100644 index 0000000..ad04f38 --- /dev/null +++ b/src/components/NodeIcon.tsx @@ -0,0 +1,23 @@ +import { Box, Cloud, Folder, Gamepad2, Mountain, Sun, Triangle } from "lucide-react"; +import type { TreeNode } from "../types"; + +function actorIcon(type: string) { + const lower = type.toLowerCase(); + if (lower.includes("directional") || lower.includes("light")) return Sun; + if (lower.includes("fog")) return Cloud; + if (lower.includes("sky") || lower.includes("atmosphere")) return Mountain; + if (lower.includes("start") || lower.includes("pawn")) return Gamepad2; + if (lower.includes("mesh")) return Box; + return Triangle; +} + +export function NodeIcon({ node }: { node: TreeNode }) { + if (node.kind === "folder") { + return ; + } + if (node.kind === "world") { + return ; + } + const Icon = actorIcon(node.type); + return ; +} diff --git a/src/components/OutlinerRow.tsx b/src/components/OutlinerRow.tsx new file mode 100644 index 0000000..ef90867 --- /dev/null +++ b/src/components/OutlinerRow.tsx @@ -0,0 +1,77 @@ +import type React from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { TreeNode } from "../types"; +import { nodeMatchesQuery } from "../models/outlinerTree"; +import { NodeIcon } from "./NodeIcon"; + +export function OutlinerRow({ + node, + depth, + expanded, + selectedId, + query, + onToggle, + onSelect, +}: { + node: TreeNode; + depth: number; + expanded: Set; + selectedId: string | null; + query: string; + onToggle: (node: TreeNode) => void; + onSelect: (node: TreeNode) => void; +}) { + const hasChildren = node.children.length > 0; + const isOpen = expanded.has(node.id); + const matches = + !query || + [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(query.toLowerCase())); + const visibleChildren = node.children.filter((child) => nodeMatchesQuery(child, query)); + + if (!matches && visibleChildren.length === 0) { + return null; + } + + return ( + <> + + {(isOpen || query) && + visibleChildren.map((child) => ( + + ))} + + ); +} diff --git a/src/lib/remoteValues.ts b/src/lib/remoteValues.ts new file mode 100644 index 0000000..b35a354 --- /dev/null +++ b/src/lib/remoteValues.ts @@ -0,0 +1,151 @@ +import type { RemoteObject } from "../types"; + +export function asRecord(value: unknown): RemoteObject { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RemoteObject) : {}; +} + +export function isObjectRecord(value: unknown): value is RemoteObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function firstString(source: RemoteObject, keys: string[], fallback = ""): string { + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return fallback; +} + +export function findStringDeep(value: unknown, keys: string[], depth = 0): string { + if (depth > 3) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + if (!isObjectRecord(value)) { + return ""; + } + + for (const key of keys) { + const direct = value[key]; + if (typeof direct === "string" && direct.trim()) { + return direct; + } + if (isObjectRecord(direct)) { + const nested = findStringDeep(direct, keys, depth + 1); + if (nested) { + return nested; + } + } + } + + for (const nestedValue of Object.values(value)) { + if (Array.isArray(nestedValue)) { + continue; + } + const nested = findStringDeep(nestedValue, keys, depth + 1); + if (nested) { + return nested; + } + } + + return ""; +} + +export function extractObjectPath(value: unknown): string { + if (typeof value === "string") { + return value; + } + + return findStringDeep(value, [ + "objectPath", + "ObjectPath", + "path", + "Path", + "actorPath", + "ActorPath", + "softObjectPath", + "SoftObjectPath", + "name", + "Name", + ]); +} + +export function nestedString(source: RemoteObject, keys: string[], fallback = ""): string { + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + const nested = asRecord(value); + const nestedValue = firstString(nested, ["Name", "name", "Path", "path", "ObjectPath", "objectPath"]); + if (nestedValue) { + return nestedValue; + } + } + return fallback; +} + +export function basename(path: string): string { + const clean = path.replace(/["']/g, ""); + const colonName = clean.split(":").pop() ?? clean; + const dotName = colonName.split(".").pop() ?? colonName; + const slashName = dotName.split("/").pop() ?? dotName; + return slashName || clean || "Actor"; +} + +export function cleanObjectName(name: string): string { + return name + .replace(/^Default__/, "") + .replace(/^UEDPIE_\d+_/, "") + .replace(/_\d+$/, (suffix) => (suffix === "_0" ? "_0" : suffix)); +} + +export function deriveLevelNameFromPath(path: string): string { + if (!path) { + return ""; + } + + const clean = path.replace(/["']/g, ""); + const packagePath = clean.includes(":") ? clean.split(":")[0] : clean; + const objectName = packagePath.split(".").pop() ?? packagePath; + return cleanObjectName(basename(objectName)); +} + +export function extractReturnValue(payload: unknown): unknown[] { + if (Array.isArray(payload)) { + return payload; + } + const record = asRecord(payload); + const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result; + if (Array.isArray(returnValue)) { + return returnValue; + } + const nested = asRecord(returnValue); + for (const value of Object.values(nested)) { + if (Array.isArray(value)) { + return value; + } + } + return []; +} + +export function extractFirstReturnString(payload: unknown): string { + const record = asRecord(payload); + const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result; + if (typeof returnValue === "string") { + return returnValue; + } + if (typeof returnValue === "number") { + return String(returnValue); + } + return firstString(asRecord(returnValue), ["Name", "name", "Label", "label", "DisplayName", "displayName"]); +} diff --git a/src/main.tsx b/src/main.tsx index ea7002d..bbc4d24 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,1135 +1,5 @@ -import React from "react"; import { createRoot } from "react-dom/client"; -import { - Box, - ChevronDown, - ChevronRight, - Cloud, - Eye, - Folder, - Gamepad2, - Layers, - Mountain, - PackagePlus, - RefreshCw, - Search, - Settings, - SlidersHorizontal, - Sun, - Triangle, - X, -} from "lucide-react"; +import { App } from "./App"; import "./styles.css"; -type RemoteObject = Record; - -type ActorRow = { - id: string; - label: string; - type: string; - level: string; - idName: string; - folderPath: string; - objectPath: string; -}; - -type ActorReference = { - raw: unknown; - objectPath: string; -}; - -type TreeNode = { - id: string; - label: string; - type: string; - level: string; - idName: string; - objectPath?: string; - kind: "world" | "folder" | "actor"; - children: TreeNode[]; -}; - -type DetailProperty = { - name: string; - displayName: string; - category: string; - type: string; - value: unknown; - description: string; -}; - -type ObjectDetails = { - name: string; - className: string; - properties: DetailProperty[]; -}; - -const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem"; -const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary"; -const UNREAL_WS_URL = import.meta.env.VITE_UNREAL_WS_URL ?? "ws://127.0.0.1:30020"; - -type RemoteTransport = "WebSocket" | "HTTP"; - -class RemoteCallError extends Error { - constructor( - message: string, - readonly isTransportError = false, - ) { - super(message); - } -} - -let activeTransport: RemoteTransport = "HTTP"; -let remoteSocket: WebSocket | null = null; -let remoteSocketPromise: Promise | null = null; -let nextRequestId = 1; -const disabledActorFunctions = new Set(); -const actorStringCallCache = new Map(); -const pendingSocketCalls = new Map< - number, - { - resolve: (payload: unknown) => void; - reject: (reason: Error) => void; - timeoutId: number; - } ->(); - -function asRecord(value: unknown): RemoteObject { - return value && typeof value === "object" && !Array.isArray(value) ? (value as RemoteObject) : {}; -} - -function isObjectRecord(value: unknown): value is RemoteObject { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function firstString(source: RemoteObject, keys: string[], fallback = ""): string { - for (const key of keys) { - const value = source[key]; - if (typeof value === "string" && value.trim()) { - return value; - } - if (typeof value === "number") { - return String(value); - } - } - return fallback; -} - -function findStringDeep(value: unknown, keys: string[], depth = 0): string { - if (depth > 3) { - return ""; - } - - if (typeof value === "string") { - return value; - } - - if (!isObjectRecord(value)) { - return ""; - } - - for (const key of keys) { - const direct = value[key]; - if (typeof direct === "string" && direct.trim()) { - return direct; - } - if (isObjectRecord(direct)) { - const nested = findStringDeep(direct, keys, depth + 1); - if (nested) { - return nested; - } - } - } - - for (const nestedValue of Object.values(value)) { - if (Array.isArray(nestedValue)) { - continue; - } - const nested = findStringDeep(nestedValue, keys, depth + 1); - if (nested) { - return nested; - } - } - - return ""; -} - -function extractObjectPath(value: unknown): string { - if (typeof value === "string") { - return value; - } - - return findStringDeep(value, [ - "objectPath", - "ObjectPath", - "path", - "Path", - "actorPath", - "ActorPath", - "softObjectPath", - "SoftObjectPath", - "name", - "Name", - ]); -} - -function nestedString(source: RemoteObject, keys: string[], fallback = ""): string { - for (const key of keys) { - const value = source[key]; - if (typeof value === "string" && value.trim()) { - return value; - } - const nested = asRecord(value); - const nestedValue = firstString(nested, ["Name", "name", "Path", "path", "ObjectPath", "objectPath"]); - if (nestedValue) { - return nestedValue; - } - } - return fallback; -} - -function basename(path: string): string { - const clean = path.replace(/["']/g, ""); - const colonName = clean.split(":").pop() ?? clean; - const dotName = colonName.split(".").pop() ?? colonName; - const slashName = dotName.split("/").pop() ?? dotName; - return slashName || clean || "Actor"; -} - -function cleanObjectName(name: string): string { - return name - .replace(/^Default__/, "") - .replace(/^UEDPIE_\d+_/, "") - .replace(/_\d+$/, (suffix) => suffix === "_0" ? "_0" : suffix); -} - -function deriveLevelNameFromPath(path: string): string { - if (!path) { - return ""; - } - - const clean = path.replace(/["']/g, ""); - const packagePath = clean.includes(":") ? clean.split(":")[0] : clean; - const objectName = packagePath.split(".").pop() ?? packagePath; - return cleanObjectName(basename(objectName)); -} - -function deriveType(actor: RemoteObject): string { - const direct = firstString(actor, ["Class", "class", "ClassName", "className", "Type", "type"]); - if (direct) { - return basename(direct).replace(/^BP_/, ""); - } - const path = firstString(actor, ["Path", "path", "ObjectPath", "objectPath"]); - const generatedClass = /\.(.+?)_C_\d+$/.exec(path)?.[1]; - return generatedClass ? basename(generatedClass) : "Actor"; -} - -function extractReturnValue(payload: unknown): unknown[] { - if (Array.isArray(payload)) { - return payload; - } - const record = asRecord(payload); - const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result; - if (Array.isArray(returnValue)) { - return returnValue; - } - const nested = asRecord(returnValue); - for (const value of Object.values(nested)) { - if (Array.isArray(value)) { - return value; - } - } - return []; -} - -function normalizeActor(value: unknown, index: number): ActorRow { - const actor = asRecord(value); - const objectPath = extractObjectPath(value); - const pathName = cleanObjectName(basename(objectPath)); - const label = firstString(actor, ["ActorLabel", "actorLabel", "Label", "label", "DisplayName", "displayName"], pathName || `Actor ${index + 1}`); - const idName = firstString(actor, ["Name", "name", "IdName", "idName"], pathName || label); - const level = - deriveLevelNameFromPath(objectPath) || - nestedString(actor, ["Level", "level", "Outer", "outer", "Package", "package"], "Persistent Level"); - const folderPath = firstString(actor, ["FolderPath", "folderPath", "Folder", "folder", "ActorFolderPath", "actorFolderPath"]); - - return { - id: objectPath || `${label}-${index}`, - label, - type: deriveType(actor), - level: deriveLevelNameFromPath(level) || basename(level).replace(/^UEDPIE_\d+_/, "") || "Persistent Level", - idName, - folderPath, - objectPath, - }; -} - -function mergeActorDetails(actor: ActorRow, details: Partial): ActorRow { - return { - ...actor, - label: details.label && details.label !== "Actor" ? details.label : actor.label, - type: details.type && details.type !== "Actor" ? details.type : actor.type, - level: details.level && details.level !== "Persistent Level" ? details.level : actor.level, - idName: details.idName && details.idName !== "Actor" ? details.idName : actor.idName, - folderPath: details.folderPath || actor.folderPath, - }; -} - -function folderSegments(path: string): string[] { - return path - .split(/[\\/|]/) - .map((part) => part.trim()) - .filter((part) => part && part.toLowerCase() !== "none"); -} - -function getOrCreateFolder(parent: TreeNode, label: string, level: string): TreeNode { - const existing = parent.children.find((child) => child.kind === "folder" && child.label === label); - if (existing) { - return existing; - } - const folder: TreeNode = { - id: `${parent.id}/folder/${label}`, - label, - type: "Folder", - level, - idName: "", - kind: "folder", - children: [], - }; - parent.children.push(folder); - return folder; -} - -function buildTree(actors: ActorRow[]): TreeNode { - const rootLevelName = actors.find((actor) => actor.level && actor.level !== "Persistent Level")?.level; - const world: TreeNode = { - id: "world", - label: `${rootLevelName || "Open World"} (Editor)`, - type: "World", - level: "", - idName: "", - kind: "world", - children: [], - }; - - for (const actor of actors) { - let parent = world; - for (const segment of folderSegments(actor.folderPath)) { - parent = getOrCreateFolder(parent, segment, actor.level); - } - parent.children.push({ - ...actor, - kind: "actor", - children: [], - }); - } - - const sortNode = (node: TreeNode) => { - node.children.sort((a, b) => { - if (a.kind !== b.kind) { - return a.kind === "folder" ? -1 : 1; - } - return a.label.localeCompare(b.label); - }); - node.children.forEach(sortNode); - }; - sortNode(world); - return world; -} - -function visibleCount(node: TreeNode): number { - return node.children.reduce((total, child) => total + (child.kind === "actor" ? 1 : visibleCount(child)), 0); -} - -function findNodeById(node: TreeNode, id: string | null): TreeNode | null { - if (!id) { - return null; - } - if (node.id === id) { - return node; - } - - for (const child of node.children) { - const found = findNodeById(child, id); - if (found) { - return found; - } - } - - return null; -} - -async function parseRemoteResponse(response: Response): Promise { - const text = await response.text(); - if (!text.trim()) { - return null; - } - - try { - return JSON.parse(text); - } catch { - return text; - } -} - -function stringifyRemoteError(payload: unknown): string { - if (typeof payload === "string") { - return payload; - } - - const record = asRecord(payload); - const direct = firstString(record, ["errorMessage", "ErrorMessage", "message", "Message", "error", "Error"]); - if (direct) { - return direct; - } - - return JSON.stringify(payload, null, 2); -} - -function getActiveTransport(): RemoteTransport { - return activeTransport; -} - -function closeRemoteSocket(reason: Error) { - for (const call of pendingSocketCalls.values()) { - window.clearTimeout(call.timeoutId); - call.reject(reason); - } - pendingSocketCalls.clear(); - remoteSocket = null; - remoteSocketPromise = null; -} - -function parseSocketMessage(data: string | Blob): Promise { - if (typeof data === "string") { - return Promise.resolve(asRecord(JSON.parse(data))); - } - - return data.text().then((text) => asRecord(JSON.parse(text))); -} - -function connectRemoteSocket(): Promise { - if (remoteSocket?.readyState === WebSocket.OPEN) { - return Promise.resolve(remoteSocket); - } - - if (remoteSocketPromise) { - return remoteSocketPromise; - } - - remoteSocketPromise = new Promise((resolve, reject) => { - const socket = new WebSocket(UNREAL_WS_URL); - const timeoutId = window.setTimeout(() => { - socket.close(); - reject(new RemoteCallError(`Timed out connecting to ${UNREAL_WS_URL}`, true)); - remoteSocketPromise = null; - }, 1500); - - socket.onopen = () => { - window.clearTimeout(timeoutId); - remoteSocket = socket; - activeTransport = "WebSocket"; - resolve(socket); - }; - - socket.onerror = () => { - window.clearTimeout(timeoutId); - reject(new RemoteCallError(`Could not connect to ${UNREAL_WS_URL}`, true)); - remoteSocket = null; - remoteSocketPromise = null; - }; - - socket.onclose = () => { - closeRemoteSocket(new RemoteCallError("Unreal Remote Control WebSocket closed", true)); - }; - - socket.onmessage = (message) => { - parseSocketMessage(message.data) - .then((payload) => { - const requestId = Number(payload.RequestId ?? payload.Id ?? payload.id); - const pending = pendingSocketCalls.get(requestId); - if (!pending) { - return; - } - - pendingSocketCalls.delete(requestId); - window.clearTimeout(pending.timeoutId); - - const responseCode = Number(payload.ResponseCode ?? payload.responseCode ?? 200); - const responseBody = payload.ResponseBody ?? payload.responseBody ?? payload; - if (responseCode >= 200 && responseCode < 300) { - pending.resolve(responseBody); - } else { - pending.reject( - new RemoteCallError(`Remote Control returned ${responseCode}: ${stringifyRemoteError(responseBody)}`), - ); - } - }) - .catch((reason) => { - console.warn("Could not parse Unreal Remote Control WebSocket message", reason); - }); - }; - }); - - return remoteSocketPromise; -} - -async function callRemoteHttpRouteHttp(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise { - activeTransport = "HTTP"; - const response = await fetch(url, { - method: verb, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - - const payload = await parseRemoteResponse(response); - - if (!response.ok) { - throw new RemoteCallError(`Remote Control returned ${response.status} ${response.statusText}: ${stringifyRemoteError(payload)}`); - } - - return payload; -} - -async function callRemoteHttpRouteWs(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise { - const socket = await connectRemoteSocket(); - const requestId = nextRequestId++; - - const response = new Promise((resolve, reject) => { - const timeoutId = window.setTimeout(() => { - pendingSocketCalls.delete(requestId); - reject(new RemoteCallError(`Timed out waiting for WebSocket response ${requestId}`, true)); - }, 8000); - - pendingSocketCalls.set(requestId, { - resolve, - reject, - timeoutId, - }); - }); - - try { - socket.send( - JSON.stringify({ - MessageName: "http", - Id: requestId, - Parameters: { - Url: url, - Verb: verb, - Body: body, - }, - }), - ); - } catch (reason) { - pendingSocketCalls.delete(requestId); - throw new RemoteCallError(reason instanceof Error ? reason.message : "Could not send WebSocket request", true); - } - - return response; -} - -async function callRemoteHttpRoute(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise { - try { - return await callRemoteHttpRouteWs(url, verb, body); - } catch (reason) { - if (reason instanceof RemoteCallError && reason.isTransportError) { - return callRemoteHttpRouteHttp(url, verb, body); - } - - throw reason; - } -} - -async function callRemoteFunction(objectPath: string, functionName: string, parameters: RemoteObject = {}): Promise { - return callRemoteHttpRoute("/remote/object/call", "PUT", { - objectPath, - functionName, - parameters, - generateTransaction: false, - }); -} - -async function describeRemoteObject(objectPath: string): Promise { - return callRemoteHttpRoute("/remote/object/describe", "PUT", { objectPath }); -} - -async function readRemoteObjectProperties(objectPath: string): Promise { - return callRemoteHttpRoute("/remote/object/property", "PUT", { - objectPath, - access: "READ_ACCESS", - }); -} - -function extractFirstReturnString(payload: unknown): string { - const record = asRecord(payload); - const returnValue = record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result; - if (typeof returnValue === "string") { - return returnValue; - } - if (typeof returnValue === "number") { - return String(returnValue); - } - return firstString(asRecord(returnValue), ["Name", "name", "Label", "label", "DisplayName", "displayName"]); -} - -function getMetadataString(property: RemoteObject, key: string): string { - const metadata = asRecord(property.Metadata ?? property.metadata); - return firstString(metadata, [key, key.toLowerCase(), key.toUpperCase()]); -} - -function prettifyPropertyName(name: string): string { - return name - .replace(/^b([A-Z])/, "$1") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/_/g, " ") - .trim(); -} - -function findPropertyValue(values: RemoteObject, name: string): unknown { - if (name in values) { - return values[name]; - } - - const lowerName = name.toLowerCase(); - const key = Object.keys(values).find((candidate) => candidate.toLowerCase() === lowerName); - return key ? values[key] : undefined; -} - -function extractPropertyValues(payload: unknown): RemoteObject { - const record = asRecord(payload); - const candidates = [ - record, - asRecord(record.ResponseBody), - asRecord(record.responseBody), - asRecord(record.Properties), - asRecord(record.properties), - ]; - - for (const candidate of candidates) { - const keys = Object.keys(candidate); - if (keys.length > 0 && !keys.includes("ResponseCode") && !keys.includes("RequestId")) { - return candidate; - } - } - - return {}; -} - -function normalizeDetails(describePayload: unknown, propertyPayload: unknown): ObjectDetails { - const description = asRecord(describePayload); - const values = extractPropertyValues(propertyPayload); - const properties = Array.isArray(description.Properties) - ? description.Properties - : Array.isArray(description.properties) - ? description.properties - : []; - - return { - name: firstString(description, ["Name", "name"], "Object"), - className: firstString(description, ["Class", "class"], "UObject"), - properties: properties.map((rawProperty): DetailProperty => { - const property = asRecord(rawProperty); - const name = firstString(property, ["Name", "name"], "Property"); - return { - name, - displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name), - category: getMetadataString(property, "Category") || "General", - type: firstString(property, ["Type", "type"], "Property"), - value: findPropertyValue(values, name), - description: firstString(property, ["Description", "description"]), - }; - }), - }; -} - -function formatDetailValue(value: unknown): string { - if (value === undefined) { - return "Unavailable"; - } - if (value === null) { - return "None"; - } - if (typeof value === "boolean") { - return value ? "True" : "False"; - } - if (typeof value === "number") { - return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/\.?0+$/, ""); - } - if (typeof value === "string") { - return value || "None"; - } - if (Array.isArray(value)) { - return `${value.length} item${value.length === 1 ? "" : "s"}`; - } - - const record = asRecord(value); - const vectorKeys = ["X", "Y", "Z"].filter((key) => key in record); - if (vectorKeys.length === 3) { - return vectorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" "); - } - - const rotatorKeys = ["Roll", "Pitch", "Yaw"].filter((key) => key in record); - if (rotatorKeys.length === 3) { - return rotatorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" "); - } - - const objectPath = extractObjectPath(value); - if (objectPath) { - return cleanObjectName(basename(objectPath)); - } - - return JSON.stringify(value); -} - -async function getAllLevelActors(): Promise { - const attempts = [ - { objectPath: EDITOR_ACTOR_SUBSYSTEM, functionName: "GetAllLevelActors" }, - { objectPath: EDITOR_LEVEL_LIBRARY, functionName: "GetAllLevelActors" }, - ]; - - const errors: string[] = []; - for (const attempt of attempts) { - try { - return await callRemoteFunction(attempt.objectPath, attempt.functionName); - } catch (reason) { - errors.push(`${attempt.objectPath}.${attempt.functionName}: ${reason instanceof Error ? reason.message : String(reason)}`); - } - } - - throw new Error(errors.join("\n\n")); -} - -async function tryRemoteString(objectPath: string, functionName: string): Promise { - if (disabledActorFunctions.has(functionName)) { - return ""; - } - - const cacheKey = `${objectPath}|${functionName}`; - if (actorStringCallCache.has(cacheKey)) { - return actorStringCallCache.get(cacheKey) ?? ""; - } - - try { - const value = extractFirstReturnString(await callRemoteFunction(objectPath, functionName)); - actorStringCallCache.set(cacheKey, value); - return value; - } catch (reason) { - if (reason instanceof RemoteCallError && !reason.isTransportError) { - disabledActorFunctions.add(functionName); - } - actorStringCallCache.set(cacheKey, ""); - return ""; - } -} - -async function tryRemoteObjectPath(objectPath: string, functionName: string): Promise { - try { - const payload = await callRemoteFunction(objectPath, functionName); - const record = asRecord(payload); - return extractObjectPath(record.ReturnValue ?? record.returnValue ?? record.Result ?? record.result); - } catch { - return ""; - } -} - -async function enrichActor(actor: ActorRow): Promise { - if (!actor.objectPath) { - return actor; - } - - const [label, folderPath] = await Promise.all([ - tryRemoteString(actor.objectPath, "GetActorLabel"), - tryRemoteString(actor.objectPath, "GetFolderPath"), - ]); - - const typeFromPath = actor.objectPath.includes(".") - ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) - : ""; - - return mergeActorDetails(actor, { - label: label || actor.label, - folderPath: folderPath || actor.folderPath, - level: deriveLevelNameFromPath(actor.objectPath) || actor.level, - type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type, - }); -} - -async function enrichActors(actors: ActorRow[]): Promise { - const sampleActor = actors.find((actor) => actor.objectPath); - if (sampleActor) { - await Promise.all([ - tryRemoteString(sampleActor.objectPath, "GetActorLabel"), - tryRemoteString(sampleActor.objectPath, "GetFolderPath"), - ]); - } - - const enriched: ActorRow[] = []; - for (let index = 0; index < actors.length; index += 4) { - enriched.push(...(await Promise.all(actors.slice(index, index + 4).map(enrichActor)))); - } - return enriched; -} - -function actorIcon(type: string) { - const lower = type.toLowerCase(); - if (lower.includes("directional") || lower.includes("light")) return Sun; - if (lower.includes("fog")) return Cloud; - if (lower.includes("sky") || lower.includes("atmosphere")) return Mountain; - if (lower.includes("start") || lower.includes("pawn")) return Gamepad2; - if (lower.includes("mesh")) return Box; - return Triangle; -} - -function NodeIcon({ node }: { node: TreeNode }) { - if (node.kind === "folder") { - return ; - } - if (node.kind === "world") { - return ; - } - const Icon = actorIcon(node.type); - return ; -} - -function OutlinerRow({ - node, - depth, - expanded, - selectedId, - query, - onToggle, - onSelect, -}: { - node: TreeNode; - depth: number; - expanded: Set; - selectedId: string | null; - query: string; - onToggle: (node: TreeNode) => void; - onSelect: (node: TreeNode) => void; -}) { - const hasChildren = node.children.length > 0; - const isOpen = expanded.has(node.id); - const matches = - !query || - [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(query.toLowerCase())); - - const visibleChildren = node.children.filter((child) => nodeMatchesQuery(child, query)); - - if (!matches && visibleChildren.length === 0) { - return null; - } - - return ( - <> - - {(isOpen || query) && - visibleChildren.map((child) => ( - - ))} - - ); -} - -function nodeMatchesQuery(node: TreeNode, query: string): boolean { - if (!query) return true; - const lower = query.toLowerCase(); - const selfMatches = [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(lower)); - return selfMatches || node.children.some((child) => nodeMatchesQuery(child, query)); -} - -function DetailsPanel({ - node, - details, - isLoading, - error, -}: { - node: TreeNode | null; - details: ObjectDetails | null; - isLoading: boolean; - error: string | null; -}) { - const [query, setQuery] = React.useState(""); - - const groupedProperties = React.useMemo(() => { - const lowerQuery = query.toLowerCase(); - const visibleProperties = (details?.properties ?? []).filter((property) => { - if (!lowerQuery) return true; - return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) => - field.toLowerCase().includes(lowerQuery), - ); - }); - - return visibleProperties.reduce>((groups, property) => { - const category = property.category || "General"; - groups[category] = groups[category] ?? []; - groups[category].push(property); - return groups; - }, {}); - }, [details, query]); - - return ( - - ); -} - -function App() { - const [actors, setActors] = React.useState([]); - const [query, setQuery] = React.useState(""); - const [expanded, setExpanded] = React.useState>(new Set(["world"])); - const [selectedId, setSelectedId] = React.useState(null); - const [status, setStatus] = React.useState("Ready"); - const [transport, setTransport] = React.useState("HTTP"); - const [isLoading, setIsLoading] = React.useState(false); - const [error, setError] = React.useState(null); - const [details, setDetails] = React.useState(null); - const [detailsError, setDetailsError] = React.useState(null); - const [isDetailsLoading, setIsDetailsLoading] = React.useState(false); - - const tree = React.useMemo(() => buildTree(actors), [actors]); - const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]); - - const refresh = React.useCallback(async () => { - setIsLoading(true); - setError(null); - setStatus("Loading actors..."); - try { - const payload = await getAllLevelActors(); - const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) })); - const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index))); - setTransport(getActiveTransport()); - setActors(rows); - setExpanded((current) => new Set([...current, "world"])); - setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`); - } catch (reason) { - const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control"; - setError(message); - setStatus("Connection failed"); - } finally { - setIsLoading(false); - } - }, []); - - React.useEffect(() => { - refresh(); - }, [refresh]); - - React.useEffect(() => { - let isCancelled = false; - - if (!selectedNode?.objectPath) { - setDetails(null); - setDetailsError(null); - setIsDetailsLoading(false); - return; - } - - setIsDetailsLoading(true); - setDetailsError(null); - - Promise.all([describeRemoteObject(selectedNode.objectPath), readRemoteObjectProperties(selectedNode.objectPath)]) - .then(([description, properties]) => { - if (!isCancelled) { - setDetails(normalizeDetails(description, properties)); - setTransport(getActiveTransport()); - } - }) - .catch((reason) => { - if (!isCancelled) { - setDetails(null); - setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties"); - } - }) - .finally(() => { - if (!isCancelled) { - setIsDetailsLoading(false); - } - }); - - return () => { - isCancelled = true; - }; - }, [selectedNode?.objectPath]); - - const toggleNode = (node: TreeNode) => { - setExpanded((current) => { - const next = new Set(current); - if (next.has(node.id)) { - next.delete(node.id); - } else { - next.add(node.id); - } - return next; - }); - }; - - return ( -
-
-
-
- - Outliner - -
-
- - Levels -
-
- - Layers -
-
- -
- - - - - - -
- -
- - Item Label - Type - Level - ID Name -
- -
-
- {error ? ( -
- {status} - {error} - Remote Control is reachable, but Unreal rejected the object call. Check the exact error above. -
- ) : ( - setSelectedId(node.id)} - /> - )} -
- - -
- -
- {query ? `${visibleCount(tree)} actors total` : `${actors.length} actor${actors.length === 1 ? "" : "s"}`} - {status} ยท {transport} -
-
-
- ); -} - createRoot(document.getElementById("root")!).render(); diff --git a/src/models/outlinerTree.ts b/src/models/outlinerTree.ts new file mode 100644 index 0000000..6f24a47 --- /dev/null +++ b/src/models/outlinerTree.ts @@ -0,0 +1,92 @@ +import type { ActorRow, TreeNode } from "../types"; + +function folderSegments(path: string): string[] { + return path + .split(/[\\/|]/) + .map((part) => part.trim()) + .filter((part) => part && part.toLowerCase() !== "none"); +} + +function getOrCreateFolder(parent: TreeNode, label: string, level: string): TreeNode { + const existing = parent.children.find((child) => child.kind === "folder" && child.label === label); + if (existing) { + return existing; + } + const folder: TreeNode = { + id: `${parent.id}/folder/${label}`, + label, + type: "Folder", + level, + idName: "", + kind: "folder", + children: [], + }; + parent.children.push(folder); + return folder; +} + +export function buildTree(actors: ActorRow[]): TreeNode { + const rootLevelName = actors.find((actor) => actor.level && actor.level !== "Persistent Level")?.level; + const world: TreeNode = { + id: "world", + label: `${rootLevelName || "Open World"} (Editor)`, + type: "World", + level: "", + idName: "", + kind: "world", + children: [], + }; + + for (const actor of actors) { + let parent = world; + for (const segment of folderSegments(actor.folderPath)) { + parent = getOrCreateFolder(parent, segment, actor.level); + } + parent.children.push({ + ...actor, + kind: "actor", + children: [], + }); + } + + const sortNode = (node: TreeNode) => { + node.children.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "folder" ? -1 : 1; + } + return a.label.localeCompare(b.label); + }); + node.children.forEach(sortNode); + }; + sortNode(world); + return world; +} + +export function visibleCount(node: TreeNode): number { + return node.children.reduce((total, child) => total + (child.kind === "actor" ? 1 : visibleCount(child)), 0); +} + +export function findNodeById(node: TreeNode, id: string | null): TreeNode | null { + if (!id) { + return null; + } + if (node.id === id) { + return node; + } + + for (const child of node.children) { + const found = findNodeById(child, id); + if (found) { + return found; + } + } + + return null; +} + +export function nodeMatchesQuery(node: TreeNode, query: string): boolean { + if (!query) return true; + const lower = query.toLowerCase(); + const selfMatches = [node.label, node.type, node.level, node.idName].some((field) => field.toLowerCase().includes(lower)); + return selfMatches || node.children.some((child) => nodeMatchesQuery(child, query)); +} diff --git a/src/services/details.ts b/src/services/details.ts new file mode 100644 index 0000000..280a3c4 --- /dev/null +++ b/src/services/details.ts @@ -0,0 +1,111 @@ +import type { DetailProperty, ObjectDetails, RemoteObject } from "../types"; +import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues"; + +function getMetadataString(property: RemoteObject, key: string): string { + const metadata = asRecord(property.Metadata ?? property.metadata); + return firstString(metadata, [key, key.toLowerCase(), key.toUpperCase()]); +} + +function prettifyPropertyName(name: string): string { + return name + .replace(/^b([A-Z])/, "$1") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); +} + +function findPropertyValue(values: RemoteObject, name: string): unknown { + if (name in values) { + return values[name]; + } + + const lowerName = name.toLowerCase(); + const key = Object.keys(values).find((candidate) => candidate.toLowerCase() === lowerName); + return key ? values[key] : undefined; +} + +function extractPropertyValues(payload: unknown): RemoteObject { + const record = asRecord(payload); + const candidates = [ + record, + asRecord(record.ResponseBody), + asRecord(record.responseBody), + asRecord(record.Properties), + asRecord(record.properties), + ]; + + for (const candidate of candidates) { + const keys = Object.keys(candidate); + if (keys.length > 0 && !keys.includes("ResponseCode") && !keys.includes("RequestId")) { + return candidate; + } + } + + return {}; +} + +export function normalizeDetails(describePayload: unknown, propertyPayload: unknown): ObjectDetails { + const description = asRecord(describePayload); + const values = extractPropertyValues(propertyPayload); + const properties = Array.isArray(description.Properties) + ? description.Properties + : Array.isArray(description.properties) + ? description.properties + : []; + + return { + name: firstString(description, ["Name", "name"], "Object"), + className: firstString(description, ["Class", "class"], "UObject"), + properties: properties.map((rawProperty): DetailProperty => { + const property = asRecord(rawProperty); + const name = firstString(property, ["Name", "name"], "Property"); + return { + name, + displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name), + category: getMetadataString(property, "Category") || "General", + type: firstString(property, ["Type", "type"], "Property"), + value: findPropertyValue(values, name), + description: firstString(property, ["Description", "description"]), + }; + }), + }; +} + +export function formatDetailValue(value: unknown): string { + if (value === undefined) { + return "Unavailable"; + } + if (value === null) { + return "None"; + } + if (typeof value === "boolean") { + return value ? "True" : "False"; + } + if (typeof value === "number") { + return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/\.?0+$/, ""); + } + if (typeof value === "string") { + return value || "None"; + } + if (Array.isArray(value)) { + return `${value.length} item${value.length === 1 ? "" : "s"}`; + } + + const record = asRecord(value); + const vectorKeys = ["X", "Y", "Z"].filter((key) => key in record); + if (vectorKeys.length === 3) { + return vectorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" "); + } + + const rotatorKeys = ["Roll", "Pitch", "Yaw"].filter((key) => key in record); + if (rotatorKeys.length === 3) { + return rotatorKeys.map((key) => `${key}: ${formatDetailValue(record[key])}`).join(" "); + } + + const objectPath = extractObjectPath(value); + if (objectPath) { + return cleanObjectName(basename(objectPath)); + } + + return JSON.stringify(value); +} diff --git a/src/services/detailsCache.ts b/src/services/detailsCache.ts new file mode 100644 index 0000000..5c2f31c --- /dev/null +++ b/src/services/detailsCache.ts @@ -0,0 +1,108 @@ +import type { CachedObjectDetails, ObjectDetails } from "../types"; + +const CACHE_PREFIX = "unreal-outliner.details."; +const CACHE_VERSION = "v1"; +const CACHE_TTL_MS = 5 * 60 * 1000; +const MAX_CACHE_ENTRIES = 150; + +function cacheKey(objectPath: string): string { + return `${CACHE_PREFIX}${CACHE_VERSION}.${objectPath}`; +} + +function isCachedObjectDetails(value: unknown): value is CachedObjectDetails { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as CachedObjectDetails; + return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && Boolean(candidate.details); +} + +function storageAvailable(): boolean { + try { + const key = `${CACHE_PREFIX}probe`; + window.localStorage.setItem(key, "1"); + window.localStorage.removeItem(key); + return true; + } catch { + return false; + } +} + +export function readCachedDetails(objectPath: string): ObjectDetails | null { + if (!storageAvailable()) { + return null; + } + + try { + const raw = window.localStorage.getItem(cacheKey(objectPath)); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw); + if (!isCachedObjectDetails(parsed) || parsed.objectPath !== objectPath) { + return null; + } + + if (Date.now() - parsed.savedAt > CACHE_TTL_MS) { + window.localStorage.removeItem(cacheKey(objectPath)); + return null; + } + + return parsed.details; + } catch { + return null; + } +} + +export function writeCachedDetails(objectPath: string, details: ObjectDetails): void { + if (!storageAvailable()) { + return; + } + + try { + const entry: CachedObjectDetails = { + objectPath, + savedAt: Date.now(), + details, + }; + window.localStorage.setItem(cacheKey(objectPath), JSON.stringify(entry)); + pruneDetailsCache(); + } catch { + pruneDetailsCache(); + } +} + +function pruneDetailsCache(): void { + const entries: Array<{ key: string; savedAt: number }> = []; + + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (!key?.startsWith(`${CACHE_PREFIX}${CACHE_VERSION}.`)) { + continue; + } + + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? ""); + if (isCachedObjectDetails(parsed)) { + entries.push({ key, savedAt: parsed.savedAt }); + } + } catch { + window.localStorage.removeItem(key); + } + } + + const expiredBefore = Date.now() - CACHE_TTL_MS; + for (const entry of entries) { + if (entry.savedAt < expiredBefore) { + window.localStorage.removeItem(entry.key); + } + } + + entries + .filter((entry) => entry.savedAt >= expiredBefore) + .sort((a, b) => b.savedAt - a.savedAt) + .slice(MAX_CACHE_ENTRIES) + .forEach((entry) => window.localStorage.removeItem(entry.key)); +} diff --git a/src/services/remoteControl.ts b/src/services/remoteControl.ts new file mode 100644 index 0000000..0f0f2b7 --- /dev/null +++ b/src/services/remoteControl.ts @@ -0,0 +1,225 @@ +import type { RemoteObject, RemoteTransport } from "../types"; +import { asRecord, firstString } from "../lib/remoteValues"; + +const UNREAL_WS_URL = import.meta.env.VITE_UNREAL_WS_URL ?? "ws://127.0.0.1:30020"; + +export class RemoteCallError extends Error { + constructor( + message: string, + readonly isTransportError = false, + ) { + super(message); + } +} + +let activeTransport: RemoteTransport = "HTTP"; +let remoteSocket: WebSocket | null = null; +let remoteSocketPromise: Promise | null = null; +let nextRequestId = 1; +const pendingSocketCalls = new Map< + number, + { + resolve: (payload: unknown) => void; + reject: (reason: Error) => void; + timeoutId: number; + } +>(); + +export function getActiveTransport(): RemoteTransport { + return activeTransport; +} + +async function parseRemoteResponse(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function stringifyRemoteError(payload: unknown): string { + if (typeof payload === "string") { + return payload; + } + + const record = asRecord(payload); + const direct = firstString(record, ["errorMessage", "ErrorMessage", "message", "Message", "error", "Error"]); + if (direct) { + return direct; + } + + return JSON.stringify(payload, null, 2); +} + +function closeRemoteSocket(reason: Error) { + for (const call of pendingSocketCalls.values()) { + window.clearTimeout(call.timeoutId); + call.reject(reason); + } + pendingSocketCalls.clear(); + remoteSocket = null; + remoteSocketPromise = null; +} + +function parseSocketMessage(data: string | Blob): Promise { + if (typeof data === "string") { + return Promise.resolve(asRecord(JSON.parse(data))); + } + + return data.text().then((text) => asRecord(JSON.parse(text))); +} + +function connectRemoteSocket(): Promise { + if (remoteSocket?.readyState === WebSocket.OPEN) { + return Promise.resolve(remoteSocket); + } + + if (remoteSocketPromise) { + return remoteSocketPromise; + } + + remoteSocketPromise = new Promise((resolve, reject) => { + const socket = new WebSocket(UNREAL_WS_URL); + const timeoutId = window.setTimeout(() => { + socket.close(); + reject(new RemoteCallError(`Timed out connecting to ${UNREAL_WS_URL}`, true)); + remoteSocketPromise = null; + }, 1500); + + socket.onopen = () => { + window.clearTimeout(timeoutId); + remoteSocket = socket; + activeTransport = "WebSocket"; + resolve(socket); + }; + + socket.onerror = () => { + window.clearTimeout(timeoutId); + reject(new RemoteCallError(`Could not connect to ${UNREAL_WS_URL}`, true)); + remoteSocket = null; + remoteSocketPromise = null; + }; + + socket.onclose = () => { + closeRemoteSocket(new RemoteCallError("Unreal Remote Control WebSocket closed", true)); + }; + + socket.onmessage = (message) => { + parseSocketMessage(message.data) + .then((payload) => { + const requestId = Number(payload.RequestId ?? payload.Id ?? payload.id); + const pending = pendingSocketCalls.get(requestId); + if (!pending) { + return; + } + + pendingSocketCalls.delete(requestId); + window.clearTimeout(pending.timeoutId); + + const responseCode = Number(payload.ResponseCode ?? payload.responseCode ?? 200); + const responseBody = payload.ResponseBody ?? payload.responseBody ?? payload; + if (responseCode >= 200 && responseCode < 300) { + pending.resolve(responseBody); + } else { + pending.reject(new RemoteCallError(`Remote Control returned ${responseCode}: ${stringifyRemoteError(responseBody)}`)); + } + }) + .catch((reason) => { + console.warn("Could not parse Unreal Remote Control WebSocket message", reason); + }); + }; + }); + + return remoteSocketPromise; +} + +async function callRemoteHttpRouteHttp(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise { + activeTransport = "HTTP"; + const response = await fetch(url, { + method: verb, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + const payload = await parseRemoteResponse(response); + + if (!response.ok) { + throw new RemoteCallError(`Remote Control returned ${response.status} ${response.statusText}: ${stringifyRemoteError(payload)}`); + } + + return payload; +} + +async function callRemoteHttpRouteWs(url: string, verb: "GET" | "PUT" | "POST" | "DELETE", body?: RemoteObject): Promise { + const socket = await connectRemoteSocket(); + const requestId = nextRequestId++; + + const response = new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + pendingSocketCalls.delete(requestId); + reject(new RemoteCallError(`Timed out waiting for WebSocket response ${requestId}`, true)); + }, 8000); + + pendingSocketCalls.set(requestId, { resolve, reject, timeoutId }); + }); + + try { + socket.send( + JSON.stringify({ + MessageName: "http", + Id: requestId, + Parameters: { + Url: url, + Verb: verb, + Body: body, + }, + }), + ); + } catch (reason) { + pendingSocketCalls.delete(requestId); + throw new RemoteCallError(reason instanceof Error ? reason.message : "Could not send WebSocket request", true); + } + + return response; +} + +export async function callRemoteHttpRoute( + url: string, + verb: "GET" | "PUT" | "POST" | "DELETE", + body?: RemoteObject, +): Promise { + try { + return await callRemoteHttpRouteWs(url, verb, body); + } catch (reason) { + if (reason instanceof RemoteCallError && reason.isTransportError) { + return callRemoteHttpRouteHttp(url, verb, body); + } + + throw reason; + } +} + +export async function callRemoteFunction(objectPath: string, functionName: string, parameters: RemoteObject = {}): Promise { + return callRemoteHttpRoute("/remote/object/call", "PUT", { + objectPath, + functionName, + parameters, + generateTransaction: false, + }); +} + +export async function describeRemoteObject(objectPath: string): Promise { + return callRemoteHttpRoute("/remote/object/describe", "PUT", { objectPath }); +} + +export async function readRemoteObjectProperties(objectPath: string): Promise { + return callRemoteHttpRoute("/remote/object/property", "PUT", { + objectPath, + access: "READ_ACCESS", + }); +} diff --git a/src/services/unrealActors.ts b/src/services/unrealActors.ts new file mode 100644 index 0000000..14baebb --- /dev/null +++ b/src/services/unrealActors.ts @@ -0,0 +1,138 @@ +import type { ActorRow, RemoteObject } from "../types"; +import { + asRecord, + basename, + cleanObjectName, + deriveLevelNameFromPath, + extractFirstReturnString, + extractObjectPath, + firstString, + nestedString, +} from "../lib/remoteValues"; +import { callRemoteFunction, RemoteCallError } from "./remoteControl"; + +const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem"; +const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary"; + +const disabledActorFunctions = new Set(); +const actorStringCallCache = new Map(); + +function deriveType(actor: RemoteObject): string { + const direct = firstString(actor, ["Class", "class", "ClassName", "className", "Type", "type"]); + if (direct) { + return basename(direct).replace(/^BP_/, ""); + } + const path = firstString(actor, ["Path", "path", "ObjectPath", "objectPath"]); + const generatedClass = /\.(.+?)_C_\d+$/.exec(path)?.[1]; + return generatedClass ? basename(generatedClass) : "Actor"; +} + +export function normalizeActor(value: unknown, index: number): ActorRow { + const actor = asRecord(value); + const objectPath = extractObjectPath(value); + const pathName = cleanObjectName(basename(objectPath)); + const label = firstString(actor, ["ActorLabel", "actorLabel", "Label", "label", "DisplayName", "displayName"], pathName || `Actor ${index + 1}`); + const idName = firstString(actor, ["Name", "name", "IdName", "idName"], pathName || label); + const level = + deriveLevelNameFromPath(objectPath) || + nestedString(actor, ["Level", "level", "Outer", "outer", "Package", "package"], "Persistent Level"); + const folderPath = firstString(actor, ["FolderPath", "folderPath", "Folder", "folder", "ActorFolderPath", "actorFolderPath"]); + + return { + id: objectPath || `${label}-${index}`, + label, + type: deriveType(actor), + level: deriveLevelNameFromPath(level) || basename(level).replace(/^UEDPIE_\d+_/, "") || "Persistent Level", + idName, + folderPath, + objectPath, + }; +} + +function mergeActorDetails(actor: ActorRow, details: Partial): ActorRow { + return { + ...actor, + label: details.label && details.label !== "Actor" ? details.label : actor.label, + type: details.type && details.type !== "Actor" ? details.type : actor.type, + level: details.level && details.level !== "Persistent Level" ? details.level : actor.level, + idName: details.idName && details.idName !== "Actor" ? details.idName : actor.idName, + folderPath: details.folderPath || actor.folderPath, + }; +} + +export async function getAllLevelActors(): Promise { + const attempts = [ + { objectPath: EDITOR_ACTOR_SUBSYSTEM, functionName: "GetAllLevelActors" }, + { objectPath: EDITOR_LEVEL_LIBRARY, functionName: "GetAllLevelActors" }, + ]; + + const errors: string[] = []; + for (const attempt of attempts) { + try { + return await callRemoteFunction(attempt.objectPath, attempt.functionName); + } catch (reason) { + errors.push(`${attempt.objectPath}.${attempt.functionName}: ${reason instanceof Error ? reason.message : String(reason)}`); + } + } + + throw new Error(errors.join("\n\n")); +} + +async function tryRemoteString(objectPath: string, functionName: string): Promise { + if (disabledActorFunctions.has(functionName)) { + return ""; + } + + const cacheKey = `${objectPath}|${functionName}`; + if (actorStringCallCache.has(cacheKey)) { + return actorStringCallCache.get(cacheKey) ?? ""; + } + + try { + const value = extractFirstReturnString(await callRemoteFunction(objectPath, functionName)); + actorStringCallCache.set(cacheKey, value); + return value; + } catch (reason) { + if (reason instanceof RemoteCallError && !reason.isTransportError) { + disabledActorFunctions.add(functionName); + } + actorStringCallCache.set(cacheKey, ""); + return ""; + } +} + +async function enrichActor(actor: ActorRow): Promise { + if (!actor.objectPath) { + return actor; + } + + const [label, folderPath] = await Promise.all([ + tryRemoteString(actor.objectPath, "GetActorLabel"), + tryRemoteString(actor.objectPath, "GetFolderPath"), + ]); + + const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : ""; + + return mergeActorDetails(actor, { + label: label || actor.label, + folderPath: folderPath || actor.folderPath, + level: deriveLevelNameFromPath(actor.objectPath) || actor.level, + type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type, + }); +} + +export async function enrichActors(actors: ActorRow[]): Promise { + const sampleActor = actors.find((actor) => actor.objectPath); + if (sampleActor) { + await Promise.all([ + tryRemoteString(sampleActor.objectPath, "GetActorLabel"), + tryRemoteString(sampleActor.objectPath, "GetFolderPath"), + ]); + } + + const enriched: ActorRow[] = []; + for (let index = 0; index < actors.length; index += 4) { + enriched.push(...(await Promise.all(actors.slice(index, index + 4).map(enrichActor)))); + } + return enriched; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4a5959e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ +export type RemoteObject = Record; + +export type RemoteTransport = "WebSocket" | "HTTP"; + +export type ActorRow = { + id: string; + label: string; + type: string; + level: string; + idName: string; + folderPath: string; + objectPath: string; +}; + +export type ActorReference = { + raw: unknown; + objectPath: string; +}; + +export type TreeNode = { + id: string; + label: string; + type: string; + level: string; + idName: string; + objectPath?: string; + kind: "world" | "folder" | "actor"; + children: TreeNode[]; +}; + +export type DetailProperty = { + name: string; + displayName: string; + category: string; + type: string; + value: unknown; + description: string; +}; + +export type ObjectDetails = { + name: string; + className: string; + properties: DetailProperty[]; +}; + +export type CachedObjectDetails = { + objectPath: string; + savedAt: number; + details: ObjectDetails; +};