Files
binary-tools/src/components/Workbench.tsx
T
2026-09-01 13:04:49 +02:00

462 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from "react";
import { bytesToHex, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
BASE58_LIMIT,
decodeInput,
encodeOutput,
formatHexRows,
MAX_BYTES,
type ByteEncoding,
} from "../core/encoding";
import {
decodeStructured,
encodeStructured,
parseDer,
parseProtobuf,
parseSchema,
type ByteNode,
} from "../core/inspect";
type Inspector = "bytes" | "cbor" | "msgpack" | "der" | "protobuf";
const EXAMPLE = new TextEncoder().encode("Hello, binary world!\n");
const BYTE_PAGE_SIZE = 4_096;
export function Workbench() {
const [bytes, setBytes] = useState<Uint8Array>(EXAMPLE);
const [encoding, setEncoding] = useState<ByteEncoding>("utf8");
const [source, setSource] = useState("Hello, binary world!\n");
const [inspector, setInspector] = useState<Inspector>("bytes");
const [structured, setStructured] = useState(
'{\n "hello": "world",\n "count": 3\n}',
);
const [schema, setSchema] = useState(
'{\n "1": { "name": "id", "type": "uint" },\n "2": { "name": "name", "type": "string" }\n}',
);
const [selection, setSelection] = useState({ start: 0, end: 0 });
const [bytePage, setBytePage] = useState(0);
const [error, setError] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const views = useMemo(
() => ({
utf8: encodeOutput(bytes, "utf8"),
hex: encodeOutput(bytes, "hex"),
base64: encodeOutput(bytes, "base64"),
base32: encodeOutput(bytes, "base32"),
base58:
bytes.length <= BASE58_LIMIT
? encodeOutput(bytes, "base58")
: "Base58 display is disabled above 4 KiB to keep this quadratic conversion responsive.",
}),
[bytes],
);
const decoded = useMemo(() => {
try {
if (inspector === "cbor" || inspector === "msgpack") {
return {
text: decodeStructured(inspector, bytes),
nodes: [] as ByteNode[],
error: "",
};
}
if (inspector === "der")
return { text: "", nodes: parseDer(bytes), error: "" };
if (inspector === "protobuf")
return {
text: "",
nodes: parseProtobuf(bytes, parseSchema(schema)),
error: "",
};
return { text: "", nodes: [] as ByteNode[], error: "" };
} catch (caught) {
return {
text: "",
nodes: [] as ByteNode[],
error: caught instanceof Error ? caught.message : String(caught),
};
}
}, [bytes, inspector, schema]);
const bytePageCount = Math.max(1, Math.ceil(bytes.length / BYTE_PAGE_SIZE));
const visibleBytePage = Math.min(bytePage, bytePageCount - 1);
const bytePageStart = visibleBytePage * BYTE_PAGE_SIZE;
const bytePageEnd = Math.min(bytes.length, bytePageStart + BYTE_PAGE_SIZE);
function selectRange(range: { start: number; end: number }): void {
setSelection(range);
if (range.start < bytes.length)
setBytePage(Math.floor(range.start / BYTE_PAGE_SIZE));
}
function applySource(): void {
try {
const next = decodeInput(source, encoding);
setBytes(next);
setSelection({ start: 0, end: 0 });
setBytePage(0);
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
}
}
function switchEncoding(next: ByteEncoding): void {
try {
const encoded = encodeOutput(bytes, next);
setEncoding(next);
setSource(encoded);
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
}
}
async function importFile(file: File | undefined): Promise<void> {
if (!file) return;
try {
if (file.size > MAX_BYTES)
throw new RangeError(
`Files are limited to ${MAX_BYTES.toLocaleString()} bytes.`,
);
const next = new Uint8Array(await file.arrayBuffer());
setBytes(next);
setEncoding("hex");
setSource(bytesToHex(next));
setSelection({ start: 0, end: 0 });
setBytePage(0);
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
if (fileInput.current) fileInput.current.value = "";
}
}
function encodeDocument(): void {
try {
if (inspector !== "cbor" && inspector !== "msgpack")
throw new Error("Choose CBOR or MessagePack to encode JSON.");
const next = encodeStructured(inspector, structured);
setBytes(next);
setEncoding("hex");
setSource(bytesToHex(next));
setSelection({ start: 0, end: next.length });
setBytePage(0);
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
}
}
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Local byte workbench</p>
<h1>Understand what the bytes say.</h1>
<p>
Convert encodings, inspect offsets and safely decode common binary
structures without uploading a file.
</p>
</div>
<span className="privacy-pill">Local only</span>
</section>
<section className="panel source-panel" aria-labelledby="source-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Canonical byte buffer</p>
<h2 id="source-title">Input</h2>
</div>
<span className="count-pill">
{bytes.length.toLocaleString()} / {MAX_BYTES.toLocaleString()} bytes
</span>
</div>
<div className="input-grid">
<label>
Encoding
<select
value={encoding}
onChange={(event) =>
switchEncoding(event.target.value as ByteEncoding)
}
>
<option value="utf8">UTF-8 text</option>
<option value="hex">Hexadecimal</option>
<option value="base64">Base64</option>
<option value="base32">Base32 (RFC 4648)</option>
<option value="base58">Base58 (Bitcoin alphabet)</option>
</select>
</label>
<div className="button-row bottom-aligned">
<button
type="button"
className="primary-button"
onClick={applySource}
>
Apply input
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Open binary file
</button>
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([new Uint8Array(bytes)]),
"binary-output.bin",
)
}
>
Save bytes
</button>
<input
ref={fileInput}
className="sr-only"
type="file"
onChange={(event) => void importFile(event.target.files?.[0])}
/>
</div>
</div>
<label className="full-label">
Encoded input
<textarea
rows={7}
spellCheck={false}
value={source}
onChange={(event) => setSource(event.target.value)}
/>
</label>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
</section>
<section className="panel" aria-labelledby="conversions-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Synchronized representations</p>
<h2 id="conversions-title">Conversions</h2>
</div>
</div>
<div className="conversion-grid">
{(Object.entries(views) as Array<[ByteEncoding, string]>).map(
([name, value]) => (
<label key={name}>
{name === "utf8"
? "UTF-8 (replacement for invalid sequences)"
: name.toUpperCase()}
<textarea
rows={4}
readOnly
value={value}
aria-label={`${name} output`}
/>
</label>
),
)}
</div>
</section>
<section className="workspace-grid">
<section className="panel" aria-labelledby="inspector-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Structure</p>
<h2 id="inspector-title">Inspector</h2>
</div>
</div>
<div
className="mode-tabs"
role="tablist"
aria-label="Inspector format"
>
{(["bytes", "cbor", "msgpack", "der", "protobuf"] as const).map(
(mode) => (
<button
key={mode}
role="tab"
aria-selected={inspector === mode}
className={inspector === mode ? "active" : ""}
onClick={() => setInspector(mode)}
>
{mode === "der"
? "ASN.1 DER"
: mode === "msgpack"
? "MessagePack"
: mode.toUpperCase()}
</button>
),
)}
</div>
{(inspector === "cbor" || inspector === "msgpack") && (
<>
<label className="full-label">
JSON to encode
<textarea
rows={8}
value={structured}
spellCheck={false}
onChange={(event) => setStructured(event.target.value)}
/>
</label>
<button
type="button"
className="primary-button"
onClick={encodeDocument}
>
Encode JSON as {inspector === "cbor" ? "CBOR" : "MessagePack"}
</button>
</>
)}
{inspector === "protobuf" && (
<label className="full-label">
Optional field schema (JSON)
<textarea
rows={8}
value={schema}
spellCheck={false}
onChange={(event) => setSchema(event.target.value)}
/>
<small>
Keys are field numbers. Types: uint, sint, bool, string, bytes,
fixed32, fixed64, float, double, message.
</small>
</label>
)}
{decoded.error && inspector !== "bytes" && (
<p className="error" role="alert">
{decoded.error}
</p>
)}
{decoded.text && (
<pre className="decoded-output" tabIndex={0}>
{decoded.text}
</pre>
)}
{decoded.nodes.length > 0 && (
<Tree nodes={decoded.nodes} onSelect={selectRange} />
)}
{inspector === "bytes" && (
<p className="muted">
Select a byte below. Structural decoders expose clickable source
ranges here.
</p>
)}
</section>
<section className="panel" aria-labelledby="bytes-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Offsets and ASCII</p>
<h2 id="bytes-title">Byte inspector</h2>
</div>
<span className="count-pill">
{selection.end > selection.start
? `${selection.start}${selection.end - 1}`
: "No range"}
</span>
</div>
{bytePageCount > 1 && (
<div className="button-row byte-pager" aria-label="Byte pages">
<button
type="button"
disabled={visibleBytePage === 0}
onClick={() => setBytePage(visibleBytePage - 1)}
>
Previous 4 KiB
</button>
<span className="muted">
Page {visibleBytePage + 1} of {bytePageCount} · bytes{" "}
{bytePageStart}{bytePageEnd - 1}
</span>
<button
type="button"
disabled={visibleBytePage === bytePageCount - 1}
onClick={() => setBytePage(visibleBytePage + 1)}
>
Next 4 KiB
</button>
</div>
)}
<div
className="byte-table"
role="grid"
aria-label="Byte inspector"
tabIndex={0}
>
{formatHexRows(
bytes.subarray(bytePageStart, bytePageEnd),
bytePageStart,
).map((row) => (
<div className="byte-row" role="row" key={row.offset}>
<code className="offset">
{row.offset.toString(16).padStart(8, "0")}
</code>
<div className="byte-cells">
{Array.from(row.bytes, (byte, index) => {
const absolute = row.offset + index;
const selected =
absolute >= selection.start && absolute < selection.end;
return (
<button
type="button"
className={selected ? "selected" : ""}
key={absolute}
aria-label={`Byte ${absolute}: ${byte.toString(16).padStart(2, "0")}`}
onClick={() =>
setSelection({ start: absolute, end: absolute + 1 })
}
>
{byte.toString(16).padStart(2, "0")}
</button>
);
})}
</div>
<code className="ascii">
{Array.from(row.bytes, (byte) =>
byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : ".",
).join("")}
</code>
</div>
))}
{bytes.length === 0 && (
<p className="muted">The buffer is empty.</p>
)}
</div>
</section>
</section>
</main>
);
}
function Tree({
nodes,
onSelect,
}: {
nodes: ByteNode[];
onSelect: (range: { start: number; end: number }) => void;
}) {
return (
<ul className="tree">
{nodes.map((node, index) => (
<li key={`${node.start}-${index}`}>
<button
type="button"
onClick={() => onSelect({ start: node.start, end: node.end })}
>
<strong>{node.label}</strong>
<span>
{node.start}{node.end - 1}
{node.value ? ` · ${node.value}` : ""}
</span>
</button>
{node.children && <Tree nodes={node.children} onSelect={onSelect} />}
</li>
))}
</ul>
);
}