/* TODO: - history - find example and correctly handle "merge" messages - filter table by message type or path - loading icons - expand referenced object in place or accordian - paste bytes -> analyze - edit fields / files? - different menu for message / enum / extend / literal - display integers in base16 and base10 - search bytes? - highlight results in inspector */ import { useState, useEffect, useRef } from 'react'; import type { MouseEvent as ReactMouseEvent, ChangeEventHandler, ChangeEvent } from 'react'; import './App.css'; import { ObjectInspector, ObjectLabel, ObjectName, ObjectValue } from 'react-inspector'; import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'; import { process, read, ParsedFile, TableItem } from './iwa'; import { parse_protos, ProtoMap } from './messages'; import type { $_TSP_MessageInfo, $_TSP_Reference } from './messages'; import { Menu, Item, Separator, useContextMenu } from 'react-contexify'; import 'react-contexify/dist/ReactContexify.css'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; const uuid2str = (l: bigint, u: bigint): string => [ ((l >> 0n) & 0xFFn).toString(16).padStart(2, "0"), ((l >> 8n) & 0xFFn).toString(16).padStart(2, "0"), ((l >> 16n) & 0xFFn).toString(16).padStart(2, "0"), ((l >> 24n) & 0xFFn).toString(16).padStart(2, "0"), "-", ((l >> 32n) & 0xFFn).toString(16).padStart(2, "0"), ((l >> 40n) & 0xFFn).toString(16).padStart(2, "0"), "-", ((l >> 48n) & 0xFFn).toString(16).padStart(2, "0"), ((l >> 56n) & 0xFFn).toString(16).padStart(2, "0"), "-", ((u >> 0n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 8n) & 0xFFn).toString(16).padStart(2, "0"), "-", ((u >> 16n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 24n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 32n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 40n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 48n) & 0xFFn).toString(16).padStart(2, "0"), ((u >> 56n) & 0xFFn).toString(16).padStart(2, "0"), ].join("").toUpperCase(); //#region Xxd import { vsprintf } from 'printj'; const X = "%02hhx", Y = X + X + " "; const FMT = [...Array.from({length:16}).map((_,i) => Y.repeat(i>>1) + (i%2 ? X:" ") + " " + " ".repeat(7 - (i >> 1)) + "|" + "%c".repeat(i) + " ".repeat(16-i) + "|\n" ), Y.repeat(8) + "|" + "%c".repeat(16) + "|\n"]; const xxd = (u8: Uint8Array): string => { let out: string[] = []; for(let i = 0; i < u8.length; i+=16) { let d = [...u8.slice(i, i+16)]; out.push(vsprintf(`%04x: ${FMT[d.length]}`, [i, ...d, ...d.map(x => String.fromCharCode(x).replace(/[^\x20-\x7E]/g,"."))])) } return out.join(""); } /*type XxdProps = { data?: Uint8Array; }; function Xxd({data}: XxdProps) { return (
{data && xxd(data)}
); }*/ //
//#endregion //#region TableView type TableViewProps = { id?: string; data: any[]; cols: string[]; sort?: string; desc?: boolean; filter?: (row: any, R: number) => boolean; rowclick?: (row: any, R: number, e: ReactMouseEvent) => void; cellclick?: (value: any, R: number, C: number, e: ReactMouseEvent) => void; onsort?: (sort: string) => void; }; function TableView({id, data, cols, filter, rowclick, cellclick, sort, desc, onsort}: TableViewProps) { return ( {cols.map((c,idx) => ( ))}{data.filter(filter || (()=>true)).map((row, R) => ( {e.preventDefault(); e.stopPropagation(); rowclick(row, R, e)}} : {})} {...(row["id"] == id ? {style: {backgroundColor: "#646cff", color: "#FFFFFF" }} : {})} >{/* TODO: forward-ref? */} {cols.map((c,C) => ())} ))}
{ if(onsort) onsort(c); }}>{c} {sort == c ? (desc ? "\u25BC" : "\u25B2") : ""}
{e.preventDefault(); e.stopPropagation(); cellclick(row, R, C, e)}} : {})} >{row[c]??""}
); } //#endregion //#region ContextMenu interface ContextMenuProps { ID: string; menuType: string; menuField: string; menuId: string; onClickId?: ({props}: any)=>void; onClickCopyByteArray?: ({props}: any)=>void; onClickCopyJSON: ({props}: any)=>void; showProtoDef: ({props}: any)=>void; showXXD: ({props}: any) => void; } const ContextMenu = ({ID, menuType, menuField, menuId, onClickId, onClickCopyByteArray, onClickCopyJSON, showProtoDef, showXXD}: ContextMenuProps) => ( {menuField && ({menuField})} {menuType} Copy byte array Copy JSON ); //#endregion //#region preparse const replacer = (_: string, v: any): any => { switch(true) { case (typeof v == "bigint"): return v.toString(); case (v instanceof Uint8Array): return [...v]; case (v != null && typeof v.lower == "bigint" && typeof v.upper == "bigint"): return uuid2str(v.lower, v.upper); } return v; }; /* parse if message has not been parsed */ const preparse = (id: any, message: string, f: ParsedFile, p: ProtoMap) => { const item = f.space[+id][0]; if(!item.parsed) { item.parsed = process(item.data, message, p); item.pre = JSON.stringify(item.parsed, replacer); } if(!item.parsedmeta) { const m: $_TSP_MessageInfo = item.parsedmeta = process(item.rawmeta, ".TSP.MessageInfo", p); /* .TSP.MessageInfo */ if(m.object_references) m.$object_references = m.object_references.map((n: BigInt) => { /* create a fake reference for the inspector */ var o: $_TSP_Reference = ({ identifier: n }); Object.defineProperty(o, "PB_TYPE", {value: ".TSP.Reference", enumerable: false}); return o; }); if(m.field_infos) m.field_infos.forEach((fi) => { /* .TSP.FieldInfo */ if(fi.object_references) fi.$object_references = fi.object_references.map((n: BigInt) => { /* create a fake reference for the inspector */ var o: $_TSP_Reference = ({ identifier: n }); Object.defineProperty(o, "PB_TYPE", {value: ".TSP.Reference", enumerable: false}); return o; }); }); } }; //#endregion function App() { /* selected message ID */ const [id, setId] = useState("0"); /* parsed file */ const [file, setFile] = useState({ space: {}, tbl: [], type: "N" }); /* current object */ const [obj, setObj] = useState({}); /* current meta */ const [meta, setMeta] = useState<$_TSP_MessageInfo>({} as any); /* current deps */ const [deps, setDeps] = useState([]); /* protobuf definitions */ const [protos, setProtos] = useState({}); /* selected message type */ const [sel, setSel] = useState(""); /* current protobuf definition */ const [__html, setProto] = useState("Select a Row"); /* "dirty" if inspecting a subfield */ const [dirty, setDirty] = useState(false); /* loading */ const [loading, setLoading] = useState(true); /* search */ const [search, setSearch] = useState(""); /* level */ const [level, setLevel] = useState(1); /* history stack */ const [stack, setStack] = useState([]); /* react-contexify */ const MENU_ID = "insp-menu"; const { show } = useContextMenu({ id: MENU_ID }); const [menuField, setMenuField] = useState(""); const [menuType, setMenuType] = useState(""); const [menuId, setMenuId] = useState(""); const tblRef = useRef(null); /* sorting */ const [ sort, setSort ] = useState(""); const [ desc, setDesc ] = useState(false); /* scroll to selected row */ const tblScroll = (R: number) => { if(R == -1) return; var rowelt = document.getElementById(`tr-${R}`); var top = rowelt?.offsetTop || 0; if(tblRef.current) { let tbl = tblRef.current; if(top > tbl.scrollTop + tbl.clientHeight - (rowelt?.clientHeight||0) || top < tbl.scrollTop + (rowelt?.clientHeight||0)) tbl.scrollTop = Math.max(0, top - tbl.clientHeight/2 - (rowelt?.clientHeight||0)/2); } }; const onsort = (s: string) => { let d = false; console.log(sort == s, desc); if(sort == s) setDesc(d = !desc); else { setDesc(d = false); setSort(s); } file.tbl.sort((x: any, y: any) => (typeof x[s] == "number" ? x[s] - y[s] : String(x[s]).localeCompare(String(y[s]))) * (d ? -1 : 1)); }; useEffect(() => { if(id) tblScroll(file.tbl.findIndex(row => row["id"] == +id)); }, [sort, desc]); /* update selection based on table row */ const doitRow = (row: TableItem, R: number, reset?: boolean) => { let obj: any, meta: $_TSP_MessageInfo; try { preparse(row.id, row.message, file, protos); const item = file.space[+row.id][0]; obj = item.parsed; meta = item.parsedmeta as $_TSP_MessageInfo; } catch(e) { console.error(row, e); toast.error(`Could not parse ${row.id} (${row.type})`, {position: toast.POSITION.TOP_CENTER}); return; } if(reset == true) setStack([]); else if(typeof reset != "undefined") setStack([...stack, id]); setSel(row.message); setProto(protos[row.message] || ""); setDirty(false); setObj(obj); setMeta(meta); setDeps(file.tbl.filter(r => ((file.space[+r.id][0].parsedmeta?.object_references||[])?.indexOf(BigInt(row.id)) > -1)).map(v => { /* this copy uses non-enumerable fields */ var o = {}; Object.entries(v).forEach(([k,v])=> Object.defineProperty(o, k, { enumerable: false, value: v })); return o; })); setId(String(row.id)); tblScroll(R); }; /* click event handler for the messages table */ const rowclick = (row: any, R: number) => { doitRow(row, R, true); }; /* helper for .TSP.Reference */ const gotoRef = (id: string) => { var R = file.tbl.findIndex(t => +t.id == +id); if (R == -1) throw new Error(`Message ${id} not found`); doitRow(file.tbl[R], R, false); }; /* helper for selecting sub-proto */ const selectProto = (type: string) => { setProto(protos[type] || ""); setDirty(type != sel); }; /* go back to the previous message */ const pop = () => { const oldId = stack.pop()||"0"; setStack([...stack]); var R = file.tbl.findIndex(t => +t.id == +oldId); if (R == -1) throw new Error(`Message ${oldId} not found`); doitRow(file.tbl[R], R); }; /* filter message table */ const filter = (row: any) => { if(!search) return true; preparse(row.id, row.message, file, protos); return (file.space[+row.id][0].pre||"").toLowerCase().indexOf(search.toLowerCase()) > -1; //return row.message == ".TN.SheetArchive"; } /* load file */ const process_ab = (ab: ArrayBuffer, p: ProtoMap = protos) => { const f = read(ab); f.tbl.forEach(row => preparse(row.id, row.message, f, p)); setFile(f); }; /* on page load, get protobuf definitions and process the test file */ useEffect(() => { const id = toast.loading(`Processing test.numbers`); let ignore = false; (async () => { await new Promise((res) => setTimeout(res, 100)); // "sleep" to give toast a chance to shine const protos = await (await fetch("protos")).text(); const testfile = await (await fetch("test.numbers")).arrayBuffer(); if(ignore) return; const p = parse_protos(protos); setProtos(p); process_ab(testfile, p); toast.dismiss(id); setLoading(false); })(); return () => { ignore = true; toast.dismiss(id); } }, []); useEffect(() => { if(file.tbl[0]) doitRow(file.tbl[0], 0, true); }, [file]); const onChange: ChangeEventHandler = async (e: ChangeEvent) => { setLoading(true); if(!e.target.files) { toast.error("must select a file!"); throw new Error("No file selected!"); setLoading(false); } try { const id = toast.loading(`Processing ${e.target.files[0].name}`); await new Promise((res) => setTimeout(res, 100)); // "sleep" to give toast a chance to shine process_ab(await e.target.files?.[0].arrayBuffer()); toast.dismiss(id); } catch(e) { if((e as any).message?.includes("Failed to read archive")) toast.error("Please select an iWork file", { position: toast.POSITION.TOP_CENTER }); else toast.error(e && (e as any).message || e, { position: toast.POSITION.TOP_CENTER }); console.error(e); } finally { e.target.value = ""; setLoading(false); } } /* Menu machinations */ interface MenuProps { type: string; field?: string; id: string; data: any; } function displayMenu(event: ReactMouseEvent, props?: MenuProps) { setMenuField(props?.field||""); setMenuType(props?.type||""); if(props?.id) setMenuId(String(props.id)); show({ event, props }); } function onClickId(){ gotoRef(menuId); } function onClickCopyByteArray({ props }: {props: MenuProps}){ var _data = props?.data?.PB_RAW?.data || (+props.id == +id) && file.space[+id][0].data; if(!_data) throw new Error("Could not find raw data"); navigator.clipboard.writeText("[" + [..._data].map(x => "0x" + x.toString(16).toUpperCase().padStart(2,"0")).join(", ") + "]"); } function onClickCopyJSON({ props }: {props: MenuProps}){ if(!props?.data) throw new Error("Could not find raw data"); navigator.clipboard.writeText(JSON.stringify(props.data, (_,v) => typeof v == "bigint" ? v.toString() : v instanceof Uint8Array ? [...v]: v)); } function showProtoDef({props}: {props: MenuProps}) { selectProto(props.type); } function showXXD({props}: {props: MenuProps}) { console.log(xxd(props.data)); } type NodeRendererProps = { depth: number; name: string; data: any; isNonenumerable: boolean; expanded: boolean; } const nodeRenderer = ({ depth, name, data, isNonenumerable }: NodeRendererProps) => { if(depth === 0) return ( Message {id} {selectProto(sel);}} onContextMenu={e => {displayMenu(e, { type: sel, id, data })}}>[{sel}] ); if(typeof data == "bigint" && name.includes("identifier")) return ( <> : -> {gotoRef(String(data))}}>{String(data)} ); if(data.PB_TYPE) { const frag = ( {selectProto(data.PB_TYPE);}}> {data.PB_ENUM && } {data.PB_ENUM && <> = {data.PB_ENUM}} [{data.PB_TYPE}] ); if(data.PB_TYPE == ".TSP.Reference") { let id = String(data?.identifier); return ( {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}> : {frag} -> {gotoRef(id)}}>{id} ); } if(data.PB_TYPE == ".TSP.UUID") { let uuid = uuid2str(data.lower, data.upper); return ( {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}> : {frag} -> {uuid} ); } return ( {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}> : {frag} ); } if(data instanceof Uint8Array) return ( {displayMenu(e, { type: "bytes", id, data, field: (data as any).PB_FIELD })}}> ); // Uncomment to show hex representation of unsigned 32-bit ints //if(typeof data == "number" && (data>>>0) == data) return ( <> // : 0x{data.toString(16)} //); return ( ); }; const metaRenderer = ({ depth, name, data, isNonenumerable }: NodeRendererProps) => { if(depth === 0) return ( Metadata ); if(typeof data == "bigint" && name.includes("identifier")) return ( <> : -> {gotoRef(String(data))}}>{String(data)} ); if(data.PB_TYPE) { const frag = ( {selectProto(data.PB_TYPE);}}> {data.PB_ENUM && } {data.PB_ENUM && <> = {data.PB_ENUM}} [{data.PB_TYPE}] ); if(data.PB_TYPE == ".TSP.Reference") { let id = String(data?.identifier); return ( {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}> : {frag} -> {gotoRef(id)}}>{id} ); } return ( {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}> : {frag} ); } return ( ); }; const depsRenderer = ({ depth, data, isNonenumerable }: NodeRendererProps) => { if(depth === 0) return ( Dependents ); return ( gotoRef(data.id)}> ); }; return ( <> {/* header */}
{/* message table */}
{/* selected message bar */}
{sel ? (<> Selected message {(stack.length > 4 ? [...stack.slice(0,2), "...", ...stack.slice(-1)] : stack).map(i => i + " > ").join("")} {id} ({sel}) {file.space[+id]?.[0]?.data?.length || 0} bytes {stack.length && Return to {stack[stack.length - 1]} || ""} ) : "Select a message to see the contents"}
{/* proto definition */}
                {dirty && (<> { selectProto(sel);}}>Return to {sel}

)} {__html.split("\n").map((r, idx) => ( <>{!r.match(/(optional|repeated|required) \./) ? ( {r} ) : ( {selectProto(r.trim().split(" ")[1]);}}>{r} )}
))}
{/* inspector */} <> Message (showing {level} level{level > 1 ? "s" : ""}) { level >= 5 ? void 0 : setLevel(level + 1)}> (+) } { level <= 1 ? void 0 : setLevel(level - 1)}> (-) } Meta {deps.length && || void 0}
{/* Menu */} {/* Toast */}
); } export default App;