refactor
This commit is contained in:
@@ -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.
|
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
|
## Run
|
||||||
|
|
||||||
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.
|
||||||
|
|||||||
197
src/App.tsx
Normal file
197
src/App.tsx
Normal file
@@ -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<ActorRow[]>([]);
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [expanded, setExpanded] = React.useState<Set<string>>(new Set(["world"]));
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
|
const [status, setStatus] = React.useState("Ready");
|
||||||
|
const [transport, setTransport] = React.useState<RemoteTransport>("HTTP");
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [details, setDetails] = React.useState<ObjectDetails | null>(null);
|
||||||
|
const [detailsError, setDetailsError] = React.useState<string | null>(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 (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="panel" aria-label="Unreal Outliner">
|
||||||
|
<header className="tabs">
|
||||||
|
<div className="tab active">
|
||||||
|
<SlidersHorizontal size={24} />
|
||||||
|
<span>Outliner</span>
|
||||||
|
<X size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="tab">
|
||||||
|
<Mountain size={23} />
|
||||||
|
<span>Levels</span>
|
||||||
|
</div>
|
||||||
|
<div className="tab">
|
||||||
|
<Layers size={23} />
|
||||||
|
<span>Layers</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<button className="icon-button" type="button" title="View options">
|
||||||
|
<SlidersHorizontal size={22} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" title="Collapse or expand root" onClick={() => toggleNode(tree)}>
|
||||||
|
{expanded.has("world") ? <ChevronDown size={21} /> : <ChevronRight size={21} />}
|
||||||
|
</button>
|
||||||
|
<label className="search">
|
||||||
|
<Search size={22} />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search..." />
|
||||||
|
</label>
|
||||||
|
<button className="icon-button" type="button" title="Refresh actors" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw size={21} className={isLoading ? "spin" : ""} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" title="Create folder">
|
||||||
|
<PackagePlus size={21} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" title="Settings">
|
||||||
|
<Settings size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="columns" role="row">
|
||||||
|
<span className="visibility">
|
||||||
|
<Eye size={20} />
|
||||||
|
</span>
|
||||||
|
<span>Item Label</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Level</span>
|
||||||
|
<span>ID Name</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-content">
|
||||||
|
<div className="rows" role="tree">
|
||||||
|
{error ? (
|
||||||
|
<div className="empty">
|
||||||
|
<strong>{status}</strong>
|
||||||
|
<span>{error}</span>
|
||||||
|
<span>Remote Control is reachable, but Unreal rejected the object call. Check the exact error above.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<OutlinerRow
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
expanded={expanded}
|
||||||
|
selectedId={selectedId}
|
||||||
|
query={query}
|
||||||
|
onToggle={toggleNode}
|
||||||
|
onSelect={(node) => setSelectedId(node.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailsPanel node={selectedNode} details={details} isLoading={isDetailsLoading} error={detailsError} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<span>{query ? `${visibleCount(tree)} actors total` : `${actors.length} actor${actors.length === 1 ? "" : "s"}`}</span>
|
||||||
|
<span>
|
||||||
|
{status} - {transport}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/DetailsPanel.tsx
Normal file
108
src/components/DetailsPanel.tsx
Normal file
@@ -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<Record<string, DetailProperty[]>>((groups, property) => {
|
||||||
|
const category = property.category || "General";
|
||||||
|
groups[category] = groups[category] ?? [];
|
||||||
|
groups[category].push(property);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
}, [details, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="details-panel" aria-label="Details">
|
||||||
|
<header className="details-tabs">
|
||||||
|
<div className="details-tab muted">
|
||||||
|
<Settings size={18} />
|
||||||
|
<span>World Settings</span>
|
||||||
|
</div>
|
||||||
|
<div className="details-tab active">
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
<span>Details</span>
|
||||||
|
<X size={15} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="details-object">
|
||||||
|
<NodeIcon node={node ?? emptyNode} />
|
||||||
|
<div>
|
||||||
|
<strong>{node?.label ?? "No Selection"}</strong>
|
||||||
|
<span>{details?.className ? basename(details.className) : node?.type ?? "Select an actor in the Outliner"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="details-search-row">
|
||||||
|
<label className="details-search">
|
||||||
|
<Search size={18} />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" />
|
||||||
|
</label>
|
||||||
|
<button className="details-tool" type="button" title="View Options">
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="details-categories">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="details-empty">Loading properties...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="details-empty">{error}</div>
|
||||||
|
) : !node?.objectPath ? (
|
||||||
|
<div className="details-empty">Select an actor to inspect exposed properties.</div>
|
||||||
|
) : Object.keys(groupedProperties).length === 0 ? (
|
||||||
|
<div className="details-empty">No readable properties found.</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(groupedProperties).map(([category, properties]) => (
|
||||||
|
<section className="detail-category" key={category}>
|
||||||
|
<h3>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<div className="detail-row" key={property.name} title={property.description || property.type}>
|
||||||
|
<span className="detail-name">{property.displayName}</span>
|
||||||
|
<span className="detail-value">{formatDetailValue(property.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/NodeIcon.tsx
Normal file
23
src/components/NodeIcon.tsx
Normal file
@@ -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 <Folder className="folder-icon" size={20} />;
|
||||||
|
}
|
||||||
|
if (node.kind === "world") {
|
||||||
|
return <Mountain className="world-icon" size={19} />;
|
||||||
|
}
|
||||||
|
const Icon = actorIcon(node.type);
|
||||||
|
return <Icon className="actor-icon" size={19} />;
|
||||||
|
}
|
||||||
77
src/components/OutlinerRow.tsx
Normal file
77
src/components/OutlinerRow.tsx
Normal file
@@ -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<string>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`outliner-row ${selectedId === node.id ? "selected" : ""}`}
|
||||||
|
style={{ "--depth": depth } as React.CSSProperties}
|
||||||
|
onClick={() => onSelect(node)}
|
||||||
|
onDoubleClick={() => hasChildren && onToggle(node)}
|
||||||
|
title={node.objectPath || node.label}
|
||||||
|
>
|
||||||
|
<span className="cell item-cell">
|
||||||
|
<span className="indent" />
|
||||||
|
<span
|
||||||
|
className="expander"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (hasChildren) onToggle(node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasChildren ? isOpen ? <ChevronDown size={17} /> : <ChevronRight size={17} /> : null}
|
||||||
|
</span>
|
||||||
|
<NodeIcon node={node} />
|
||||||
|
<span className="label">{node.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="cell type-cell">{node.type}</span>
|
||||||
|
<span className="cell level-cell">{node.level}</span>
|
||||||
|
<span className="cell id-cell">{node.idName}</span>
|
||||||
|
</button>
|
||||||
|
{(isOpen || query) &&
|
||||||
|
visibleChildren.map((child) => (
|
||||||
|
<OutlinerRow
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
expanded={expanded}
|
||||||
|
selectedId={selectedId}
|
||||||
|
query={query}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/lib/remoteValues.ts
Normal file
151
src/lib/remoteValues.ts
Normal file
@@ -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"]);
|
||||||
|
}
|
||||||
1132
src/main.tsx
1132
src/main.tsx
File diff suppressed because it is too large
Load Diff
92
src/models/outlinerTree.ts
Normal file
92
src/models/outlinerTree.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
111
src/services/details.ts
Normal file
111
src/services/details.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
108
src/services/detailsCache.ts
Normal file
108
src/services/detailsCache.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
225
src/services/remoteControl.ts
Normal file
225
src/services/remoteControl.ts
Normal file
@@ -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<WebSocket> | 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<unknown> {
|
||||||
|
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<RemoteObject> {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return Promise.resolve(asRecord(JSON.parse(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.text().then((text) => asRecord(JSON.parse(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectRemoteSocket(): Promise<WebSocket> {
|
||||||
|
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<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
const socket = await connectRemoteSocket();
|
||||||
|
const requestId = nextRequestId++;
|
||||||
|
|
||||||
|
const response = new Promise<unknown>((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<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
return callRemoteHttpRoute("/remote/object/call", "PUT", {
|
||||||
|
objectPath,
|
||||||
|
functionName,
|
||||||
|
parameters,
|
||||||
|
generateTransaction: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeRemoteObject(objectPath: string): Promise<unknown> {
|
||||||
|
return callRemoteHttpRoute("/remote/object/describe", "PUT", { objectPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRemoteObjectProperties(objectPath: string): Promise<unknown> {
|
||||||
|
return callRemoteHttpRoute("/remote/object/property", "PUT", {
|
||||||
|
objectPath,
|
||||||
|
access: "READ_ACCESS",
|
||||||
|
});
|
||||||
|
}
|
||||||
138
src/services/unrealActors.ts
Normal file
138
src/services/unrealActors.ts
Normal file
@@ -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<string>();
|
||||||
|
const actorStringCallCache = new Map<string, string>();
|
||||||
|
|
||||||
|
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>): 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<unknown> {
|
||||||
|
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<string> {
|
||||||
|
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<ActorRow> {
|
||||||
|
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<ActorRow[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
50
src/types.ts
Normal file
50
src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export type RemoteObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user