Release Binary Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user