Release Binary Tools 0.1.0

This commit is contained in:
2026-09-01 13:04:49 +02:00
commit 60768466c1
59 changed files with 10424 additions and 0 deletions
+461
View File
@@ -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>
);
}