This commit is contained in:
SheetJS 2023-05-16 21:56:27 -04:00
parent 244f5e4c78
commit cf1472d268
24 changed files with 18260 additions and 0 deletions

14
.eslintrc.cjs Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
Makefile Normal file
View File

@ -0,0 +1,16 @@
.PHONY: build
build:
npm run build
.PHONY: dev
dev:
npm run dev
.PHONY: deps
deps:
# protos
deno run -rA misc/otorp.ts > public/protos
# src/messages
deno run -rA misc/dump_registry.ts /Applications/Numbers.app/Contents/MacOS/Numbers > src/messages/numbers.ts
deno run -rA misc/dump_registry.ts /Applications/Keynote.app/Contents/MacOS/Keynote > src/messages/keynote.ts
deno run -rA misc/dump_registry.ts /Applications/Pages.app/Contents/MacOS/Pages > src/messages/pages.ts

25
README.md Normal file
View File

@ -0,0 +1,25 @@
# iwa-inspector
source for <https://sheetjs.com/tools/iwa-inspector>
`iwa-inspector` is a tool for inspecting iWork archives.
When a file is loaded, a table will display the messages in the file.
When a message is selected, the page will display the Protocol Buffers
definition for the message as well as an inspector for the message and metadata.
Clicking on a `.TSP.Reference` ID will jump to the referenced message.
Right-clicking a custom message type will show a context menu with options to
copy the raw byte representation (array of numbers) or parsed object (JSON).
## Development
`make dev` starts the dev server.
`make build` generates the static site.
## Refreshing data
`make deps` requires a SIP-disabled Intel Mac. The last run used v13.0 apps.

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="https://sheetjs.com/favico/favicon-196x196.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SheetJS IWA Inspector</title>
<link rel="canonical" href="https://sheetjs.com/tools/iwa-inspector" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

57
misc/dump_registry.ts Normal file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env -S deno run -A
/*! dump_registry.ts (C) 2022-present SheetJS LLC -- https://sheetjs.com */
/*
NOTE: this script requires an Intel Mac, Numbers, LLDB, and Deno
USAGE: deno run -A https://oss.sheetjs.com/notes/iwa/dump_registry.ts
*/
if(Deno.build.os != "darwin") throw `Must run in macOS!`;
if(Deno.build.arch != "x86_64") throw `Must run on Intel Mac (Apple Silicon currently unsupported)`;
{
const p = Deno.run({cmd:["csrutil","status"],stdin:"piped",stdout:"piped" });
const [status, stdout] = await Promise.all([ p.status(), p.output() ]);
await p.close();
const data = new TextDecoder().decode(stdout);
//if(data.includes("enabled")) throw `SIP must be disabled!`;
}
const p = Deno.run({ cmd: `lldb ${Deno?.args?.[0] || "/Applications/Numbers.app/Contents/MacOS/Numbers"} -a x86_64`.split(" "),
stdin: "piped", stdout: "piped"
});
const doit = (x: string) => p?.stdin?.write(new TextEncoder().encode(x))
const cmds = [
"b -[NSApplication _sendFinishLaunchingNotification]",
"settings set auto-confirm 1",
"breakpoint command add 1.1",
"po [TSPRegistry sharedRegistry]",
"process kill",
"exit",
"DONE",
"run",
];
for(const cmd of cmds) await doit(cmd + "\n");
/* LLDB does not exit normally, setTimeout workaround */
setTimeout(() => p.kill("SIGKILL"), 15000)
const [status, stdout] = await Promise.all([ p.status(), p.output() ]);
await p.close();
const data = new TextDecoder().decode(stdout);
const res = data.match(/_messageTypeToPrototypeMap = {([^]*?)}/m)?.[1];
if(!res) throw `Could not find map!`
const rows = res.split(/[\r\n]+/).map(r => r.trim().split(/\s+/)).filter(x => x.length > 1);
rows.sort((l, r) => +l[0] - +r[0]);
console.log(`export default {`);
rows.forEach(r => {
if(r[3] == "null") return;
console.log(` ${r[0]}: ".${r[3]}",`);
});
console.log(`} as {[key: number]: string};`);
//console.log(Object.fromEntries(rows.map(r => [r[0], r[3]]).filter(r => r[1] != "null")));

625
misc/otorp.ts Normal file
View File

@ -0,0 +1,625 @@
#!/usr/bin/env -S deno run -A
/*! otorp (C) 2021-present SheetJS -- http://sheetjs.com */
import { resolve } from "https://deno.land/std@0.171.0/path/mod.ts";
import { TerminalSpinner } from "https://deno.land/x/spinners/mod.ts";
// #region util.ts
var u8_to_dataview = (array: Uint8Array): DataView => new DataView(array.buffer, array.byteOffset, array.byteLength);
var u8str = (u8: Uint8Array): string => new TextDecoder().decode(u8);
var u8concat = (u8a: Uint8Array[]): Uint8Array => {
var len = u8a.reduce((acc: number, x: Uint8Array) => acc + x.length, 0);
var out = new Uint8Array(len);
var off = 0;
u8a.forEach(u8 => { out.set(u8, off); off += u8.length; });
return out;
};
var indent = (str: string, depth: number /* = 1 */): string => str.split(/\n/g).map(x => x && " ".repeat(depth) + x).join("\n");
function u8indexOf(u8: Uint8Array, data: string | number | Uint8Array, byteOffset?: number): number {
//if(Buffer.isBuffer(u8)) return u8.indexOf(data, byteOffset);
if(typeof data == "number") return u8.indexOf(data, byteOffset);
var l = byteOffset;
if(typeof data == "string") {
outs: while((l = u8.indexOf(data.charCodeAt(0), l)) > -1) {
++l;
for(var j = 1; j < data.length; ++j) if(u8[l+j-1] != data.charCodeAt(j)) continue outs;
return l - 1;
}
} else {
outb: while((l = u8.indexOf(data[0], l)) > -1) {
++l;
for(var j = 1; j < data.length; ++j) if(u8[l+j-1] != data[j]) continue outb;
return l - 1;
}
}
return -1;
}
// #endregion
// #region proto.ts
type Ptr = [number];
/** Parse an integer from the varint that can be exactly stored in a double */
function parse_varint49(buf: Uint8Array, ptr?: Ptr): number {
var l = ptr ? ptr[0] : 0;
var usz = buf[l] & 0x7F;
varint: if(buf[l++] >= 0x80) {
usz |= (buf[l] & 0x7F) << 7; if(buf[l++] < 0x80) break varint;
usz |= (buf[l] & 0x7F) << 14; if(buf[l++] < 0x80) break varint;
usz |= (buf[l] & 0x7F) << 21; if(buf[l++] < 0x80) break varint;
usz += (buf[l] & 0x7F) * Math.pow(2, 28); ++l; if(buf[l++] < 0x80) break varint;
usz += (buf[l] & 0x7F) * Math.pow(2, 35); ++l; if(buf[l++] < 0x80) break varint;
usz += (buf[l] & 0x7F) * Math.pow(2, 42); ++l; if(buf[l++] < 0x80) break varint;
}
if(ptr) ptr[0] = l;
return usz;
}
function write_varint49(v: number): Uint8Array {
var usz = new Uint8Array(7);
usz[0] = (v & 0x7F);
var L = 1;
sz: if(v > 0x7F) {
usz[L-1] |= 0x80; usz[L] = (v >> 7) & 0x7F; ++L;
if(v <= 0x3FFF) break sz;
usz[L-1] |= 0x80; usz[L] = (v >> 14) & 0x7F; ++L;
if(v <= 0x1FFFFF) break sz;
usz[L-1] |= 0x80; usz[L] = (v >> 21) & 0x7F; ++L;
if(v <= 0xFFFFFFF) break sz;
usz[L-1] |= 0x80; usz[L] = ((v/0x100) >>> 21) & 0x7F; ++L;
if(v <= 0x7FFFFFFFF) break sz;
usz[L-1] |= 0x80; usz[L] = ((v/0x10000) >>> 21) & 0x7F; ++L;
if(v <= 0x3FFFFFFFFFF) break sz;
usz[L-1] |= 0x80; usz[L] = ((v/0x1000000) >>> 21) & 0x7F; ++L;
}
return usz.slice(0, L);
}
/** Parse a 32-bit signed integer from the raw varint */
function varint_to_i32(buf: Uint8Array): number {
var l = 0, i32 = buf[l] & 0x7F;
varint: if(buf[l++] >= 0x80) {
i32 |= (buf[l] & 0x7F) << 7; if(buf[l++] < 0x80) break varint;
i32 |= (buf[l] & 0x7F) << 14; if(buf[l++] < 0x80) break varint;
i32 |= (buf[l] & 0x7F) << 21; if(buf[l++] < 0x80) break varint;
i32 |= (buf[l] & 0x7F) << 28;
}
return i32;
}
interface ProtoItem {
offset?: number;
data: Uint8Array;
type: number;
}
type ProtoField = Array<ProtoItem>
type ProtoMessage = Array<ProtoField>;
/** Shallow parse of a message */
function parse_shallow(buf: Uint8Array): ProtoMessage {
var out: ProtoMessage = [], ptr: Ptr = [0];
while(ptr[0] < buf.length) {
var off = ptr[0];
var num = parse_varint49(buf, ptr);
var type = num & 0x07; num = Math.floor(num / 8);
var len = 0;
var res: Uint8Array;
if(num == 0) break;
switch(type) {
case 0: {
var l = ptr[0];
while(buf[ptr[0]++] >= 0x80);
res = buf.slice(l, ptr[0]);
} break;
case 5: len = 4; res = buf.slice(ptr[0], ptr[0] + len); ptr[0] += len; break;
case 1: len = 8; res = buf.slice(ptr[0], ptr[0] + len); ptr[0] += len; break;
case 2: len = parse_varint49(buf, ptr); res = buf.slice(ptr[0], ptr[0] + len); ptr[0] += len; break;
case 3: // Start group
case 4: // End group
default: throw new Error(`PB Type ${type} for Field ${num} at offset ${off}`);
}
var v: ProtoItem = { offset: off, data: res, type };
if(out[num] == null) out[num] = [v];
else out[num].push(v);
}
return out;
}
/** Serialize a shallow parse */
function write_shallow(proto: ProtoMessage): Uint8Array {
var out: Uint8Array[] = [];
proto.forEach((field, idx) => {
field.forEach(item => {
out.push(write_varint49(idx * 8 + item.type));
out.push(item.data);
});
});
return u8concat(out);
}
function mappa<U>(data: ProtoField, cb:(_:Uint8Array) => U): U[] {
if(!data) return [];
return data.map((d) => { try {
return cb(d.data);
} catch(e) {
var m = e.message?.match(/at offset (\d+)/);
if(m) e.message = e.message.replace(/at offset (\d+)/, "at offset " + (+m[1] + (d.offset||0)));
throw e;
}});
}
// #endregion
// #region descriptor.ts
var TYPES = [
"error",
"double",
"float",
"int64",
"uint64",
"int32",
"fixed64",
"fixed32",
"bool",
"string",
"group",
"message",
"bytes",
"uint32",
"enum",
"sfixed32",
"sfixed64",
"sint32",
"sint64"
];
interface FileOptions {
javaPackage?: string;
javaOuterClassname?: string;
javaMultipleFiles?: string;
goPackage?: string;
}
function parse_FileOptions(buf: Uint8Array): FileOptions {
var data = parse_shallow(buf);
var out: FileOptions = {};
if(data[1]?.[0]) out.javaPackage = u8str(data[1][0].data);
if(data[8]?.[0]) out.javaOuterClassname = u8str(data[8][0].data);
if(data[11]?.[0]) out.goPackage = u8str(data[11][0].data);
return out;
}
interface EnumValue {
name?: string;
number?: number;
}
function parse_EnumValue(buf: Uint8Array): EnumValue {
var data = parse_shallow(buf);
var out: EnumValue = {};
if(data[1]?.[0]) out.name = u8str(data[1][0].data);
if(data[2]?.[0]) out.number = varint_to_i32(data[2][0].data);
return out;
}
interface Enum {
name?: string;
value?: EnumValue[];
}
function parse_Enum(buf: Uint8Array): Enum {
var data = parse_shallow(buf);
var out: Enum = {};
if(data[1]?.[0]) out.name = u8str(data[1][0].data);
out.value = mappa(data[2], parse_EnumValue);
return out;
}
var write_Enum = (en: Enum, pkg?: string): string => {
var out = [`enum ${pkg ? `.${pkg}.` : ""}${en.name} {`];
en.value?.forEach(({name, number}) => out.push(` ${name} = ${number};`));
return out.concat(`}`).join("\n");
};
interface FieldOptions {
packed?: boolean;
deprecated?: boolean;
}
function parse_FieldOptions(buf: Uint8Array): FieldOptions {
var data = parse_shallow(buf);
var out: FieldOptions = {};
if(data[2]?.[0]) out.packed = !!data[2][0].data;
if(data[3]?.[0]) out.deprecated = !!data[3][0].data;
return out;
}
interface Field {
name?: string;
extendee?: string;
number?: number;
label?: number;
type?: number;
typeName?: string;
defaultValue?: string;
options?: FieldOptions;
}
function parse_Field(buf: Uint8Array): Field {
var data = parse_shallow(buf);
var out: Field = {};
if(data[1]?.[0]) out.name = u8str(data[1][0].data);
if(data[2]?.[0]) out.extendee = u8str(data[2][0].data);
if(data[3]?.[0]) out.number = varint_to_i32(data[3][0].data);
if(data[4]?.[0]) out.label = varint_to_i32(data[4][0].data);
if(data[5]?.[0]) out.type = varint_to_i32(data[5][0].data);
if(data[6]?.[0]) out.typeName = u8str(data[6][0].data);
if(data[7]?.[0]) out.defaultValue = u8str(data[7][0].data);
if(data[8]?.[0]) out.options = parse_FieldOptions(data[8][0].data);
return out;
}
function write_Field(field: Field): string {
var out = [];
var label = ["", "optional ", "required ", "repeated "][field.label||0] || "";
var type = field.typeName || TYPES[field.type||69] || "s5s";
var opts = [];
if(field.defaultValue) opts.push(`default = ${field.defaultValue}`);
if(field.options?.packed) opts.push(`packed = true`);
if(field.options?.deprecated) opts.push(`deprecated = true`);
var os = opts.length ? ` [${opts.join(", ")}]`: "";
out.push(`${label}${type} ${field.name} = ${field.number}${os};`);
return out.length ? indent(out.join("\n"), 1) : "";
}
function write_extensions(ext: Field[], xtra = false, coalesce = true): string {
var res: string[] = [];
var xt: Array<[string, Array<Field>]> = [];
ext.forEach(ext => {
if(!ext.extendee) return;
var row = coalesce ?
xt.find(x => x[0] == ext.extendee) :
(xt[xt.length - 1]?.[0] == ext.extendee ? xt[xt.length - 1]: null);
if(row) row[1].push(ext);
else xt.push([ext.extendee, [ext]]);
});
xt.forEach(extrow => {
var out = [`extend ${extrow[0]} {`];
extrow[1].forEach(ext => out.push(write_Field(ext)));
res.push(out.concat(`}`).join("\n") + (xtra ? "\n" : ""));
});
return res.join("\n");
}
interface ExtensionRange { start?: number; end?: number; }
interface MessageType {
name?: string;
nestedType?: MessageType[];
enumType?: Enum[];
field?: Field[];
extension?: Field[];
extensionRange?: ExtensionRange[];
}
function parse_mtype(buf: Uint8Array): MessageType {
var data = parse_shallow(buf);
var out: MessageType = {};
if(data[1]?.[0]) out.name = u8str(data[1][0].data);
if(data[2]?.length >= 1) out.field = mappa(data[2], parse_Field);
if(data[3]?.length >= 1) out.nestedType = mappa(data[3], parse_mtype);
if(data[4]?.length >= 1) out.enumType = mappa(data[4], parse_Enum);
if(data[6]?.length >= 1) out.extension = mappa(data[6], parse_Field);
if(data[5]?.length >= 1) out.extensionRange = data[5].map(d => {
var data = parse_shallow(d.data);
var out: ExtensionRange = {};
if(data[1]?.[0]) out.start = varint_to_i32(data[1][0].data);
if(data[2]?.[0]) out.end = varint_to_i32(data[2][0].data);
return out;
});
return out;
}
var write_mtype = (message: MessageType, pkg?: string): string => {
var out = [ `message ${pkg ? `.${pkg}.` : ""}${message.name} {` ];
message.nestedType?.forEach(m => out.push(indent(write_mtype(m), 1)));
message.enumType?.forEach(en => out.push(indent(write_Enum(en), 1)));
message.field?.forEach(field => out.push(write_Field(field)));
if(message.extensionRange) message.extensionRange.forEach(er => out.push(` extensions ${er.start} to ${(er.end||0) - 1};`));
if(message.extension?.length) out.push(indent(write_extensions(message.extension), 1));
return out.concat(`}`).join("\n");
};
interface Descriptor {
name?: string;
package?: string;
dependency?: string[];
messageType?: MessageType[];
enumType?: Enum[];
extension?: Field[];
options?: FileOptions;
}
function parse_FileDescriptor(buf: Uint8Array): Descriptor {
var data = parse_shallow(buf);
var out: Descriptor = {};
if(data[1]?.[0]) out.name = u8str(data[1][0].data);
if(data[2]?.[0]) out.package = u8str(data[2][0].data);
if(data[3]?.[0]) out.dependency = data[3].map(x => u8str(x.data));
if(data[4]?.length >= 1) out.messageType = mappa(data[4], parse_mtype);
if(data[5]?.length >= 1) out.enumType = mappa(data[5], parse_Enum);
if(data[7]?.length >= 1) out.extension = mappa(data[7], parse_Field);
if(data[8]?.[0]) out.options = parse_FileOptions(data[8][0].data);
return out;
}
var write_FileDescriptor = (pb: Descriptor): string => {
var out = [
// 'syntax = "proto2";',
// ''
];
// if(pb.dependency) pb.dependency.forEach((n: string) => { if(n) out.push(`import "${n}";`); });
// if(pb.package) out.push(`package ${pb.package};\n`);
/* if(pb.options) {
var o = out.length;
if(pb.options.javaPackage) out.push(`option java_package = "${pb.options.javaPackage}";`);
if(pb.options.javaOuterClassname?.replace(/\W/g, "")) out.push(`option java_outer_classname = "${pb.options.javaOuterClassname}";`);
if(pb.options.javaMultipleFiles) out.push(`option java_multiple_files = true;`);
if(pb.options.goPackage) out.push(`option go_package = "${pb.options.goPackage}";`);
if(out.length > o) out.push('');
}*/
pb.enumType?.forEach(en => { if(en.name) out.push(write_Enum(en, pb.package) + "\n"); });
pb.messageType?.forEach(m => { if(m.name) { var o = write_mtype(m, pb.package); if(o) out.push(o + "\n"); }});
if(pb.extension?.length) {
var e = write_extensions(pb.extension, true, false);
if(e) out.push(e);
}
return out.join("\n") + "\n";
};
// #endregion
// #region macho.ts
interface MachOEntry {
type: number;
subtype: number;
offset: number;
size: number;
align?: number;
data: Uint8Array;
}
var parse_fat = (buf: Uint8Array): MachOEntry[] => {
var dv = u8_to_dataview(buf);
if(dv.getUint32(0, false) !== 0xCAFEBABE) throw new Error("Unsupported file");
var nfat_arch = dv.getUint32(4, false);
var out: MachOEntry[] = [];
for(var i = 0; i < nfat_arch; ++i) {
var start = i * 20 + 8;
var cputype = dv.getUint32(start, false);
var cpusubtype = dv.getUint32(start+4, false);
var offset = dv.getUint32(start+8, false);
var size = dv.getUint32(start+12, false);
var align = dv.getUint32(start+16, false);
out.push({
type: cputype,
subtype: cpusubtype,
offset,
size,
align,
data: buf.slice(offset, offset + size)
});
}
return out;
};
var parse_macho = (buf: Uint8Array): MachOEntry[] => {
var dv = u8_to_dataview(buf);
var magic = dv.getUint32(0, false);
switch(magic) {
// fat binary (x86_64 / aarch64)
case 0xCAFEBABE: return parse_fat(buf);
// x86_64
case 0xCFFAEDFE: return [{
type: dv.getUint32(4, false),
subtype: dv.getUint32(8, false),
offset: 0,
size: buf.length,
data: buf
}];
}
throw new Error("Unsupported file");
};
// #endregion
// #region otorp.ts
interface OtorpEntry {
name: string;
proto: string;
}
/** Find and stringify all relevant protobuf defs */
function otorp(buf: Uint8Array, builtins = false): OtorpEntry[] {
var res = proto_offsets(buf);
var registry: {[key: string]: Descriptor} = {};
var names: Set<string> = new Set();
var out: OtorpEntry[] = [];
res.forEach((r, i) => {
if(!builtins && r[1].startsWith("google/protobuf/")) return;
var b = buf.slice(r[0], i < res.length - 1 ? res[i+1][0] : buf.length);
var pb = parse_FileDescriptorProto(b/*, r[1]*/);
names.add(r[1]);
registry[r[1]] = pb;
});
names.forEach(name => {
/* ensure partial ordering by dependencies */
names.delete(name);
var pb = registry[name];
var doit = (pb.dependency||[]).every((d: string) => !names.has(d));
if(!doit) { names.add(name); return; }
var dups = res.filter(r => r[1] == name);
if(dups.length == 1) return out.push({ name, proto: write_FileDescriptor(pb) });
/* in a fat binary, compare the defs for x86_64/aarch64 */
var pbs = dups.map(r => {
var i = res.indexOf(r);
var b = buf.slice(r[0], i < res.length - 1 ? res[i+1][0] : buf.length);
var pb = parse_FileDescriptorProto(b/*, r[1]*/);
return write_FileDescriptor(pb);
});
for(var l = 1; l < pbs.length; ++l) if(pbs[l] != pbs[0]) throw new Error(`Conflicting definitions for ${name} at offsets 0x${dups[0][0].toString(16)} and 0x${dups[l][0].toString(16)}`);
return out.push({ name, proto: pbs[0] });
});
return out;
}
export default otorp;
/** Determine if an address is being referenced */
var is_referenced = (buf: Uint8Array, pos: number): boolean => {
var dv = u8_to_dataview(buf);
/* Search for LEA reference (x86) */
for(var leaddr = 0; leaddr > -1 && leaddr < pos; leaddr = u8indexOf(buf, 0x8D, leaddr + 1))
if(dv.getUint32(leaddr + 2, true) == pos - leaddr - 6) return true;
/* Search for absolute reference to address */
try {
var headers = parse_macho(buf);
for(var i = 0; i < headers.length; ++i) {
if(pos < headers[i].offset || pos > headers[i].offset + headers[i].size) continue;
var b = headers[i].data;
var p = pos - headers[i].offset;
var ref = new Uint8Array([0,0,0,0,0,0,0,0]);
var dv = u8_to_dataview(ref);
dv.setUint32(0, p, true);
if(u8indexOf(b, ref, 0) > 0) return true;
ref[4] = 0x01;
if(u8indexOf(b, ref, 0) > 0) return true;
ref[4] = 0x00; ref[6] = 0x10;
if(u8indexOf(b, ref, 0) > 0) return true;
}
} catch(e) {throw e}
return false;
};
type OffsetList = Array<[number, string, number, number]>;
/** Generate a list of potential starting points */
var proto_offsets = (buf: Uint8Array): OffsetList => {
var meta = parse_macho(buf);
var out: OffsetList = [];
var off = 0;
/* note: this loop only works for names < 128 chars */
search: while((off = u8indexOf(buf, ".proto", off + 1)) > -1) {
var pos = off;
off += 6;
while(off - pos < 256 && buf[pos] != off - pos - 1) {
if(buf[pos] > 0x7F || buf[pos] < 0x20) continue search;
--pos;
}
if(off - pos > 250) continue;
var name = u8str(buf.slice(pos + 1, off));
if(buf[--pos] != 0x0A) continue;
if(!is_referenced(buf, pos)) { console.error(`Reference to ${name} at ${pos} not found`); continue; }
var bin = meta.find(m => m.offset <= pos && m.offset + m.size >= pos);
out.push([pos, name, bin?.type || -1, bin?.subtype || -1]);
}
return out;
};
/** Parse a descriptor that starts with the first byte of the supplied buffer */
var parse_FileDescriptorProto = (buf: Uint8Array): Descriptor => {
var l = buf.length;
while(l > 0) try {
var b = buf.slice(0,l);
var o = parse_FileDescriptor(b);
return o;
} catch(e) {
var m = e.message.match(/at offset (\d+)/);
if(m && parseInt(m[1], 10) < buf.length) l = parseInt(m[1], 10) - 1;
else --l;
}
throw new RangeError("no protobuf message in range");
};
// #endregion
let spin: TerminalSpinner;
const width = Deno.consoleSize().columns;
const seen: any = {};
function process(inf: string, outf: string) {
if(!inf) ["Numbers", "Keynote", "Pages"].forEach(app => {
const inf = `/Applications/${app}.app`;
for(let info of Deno.readDirSync(inf)) {
if(spin) spin.set(inf.length > width - 4 ? "…" + inf.slice(-(width-4)) : inf);
process(inf + (inf.slice(-1) == "/" ? "" : "/") + info.name, outf);
}
});
else {
const fi = Deno.statSync(inf);
if(fi.isDirectory) for(let info of Deno.readDirSync(inf)) {
if(spin) spin.set(inf.length > width - 4 ? "…" + inf.slice(-(width-4)) : inf);
process(inf + (inf.slice(-1) == "/" ? "" : "/") + info.name, outf);
}
}
try {
const buf: Uint8Array = Deno.readFileSync(inf);
var dv = u8_to_dataview(buf);
var magic = dv.getUint32(0, false);
if(![0xCAFEBABE, 0xCFFAEDFE].includes(magic)) return;
otorp(buf).forEach(({name, proto}) => {
if(!outf) {
/* NOTE: this logic assumes the protos do not conflict */
if(seen[name]) { if(seen[name] != proto) throw new Error(name); return; }
seen[name] = proto;
return console.log(proto);
}
var pth = resolve(outf || "./", name.replace(/[/]/g, "$"));
try {
const str = Deno.readTextFileSync(pth);
if(str == proto) return;
throw `${pth} definition diverges!`;
} catch(e) { if(typeof e == "string") throw e; }
console.error(`writing ${name} to ${pth}`);
Deno.writeTextFileSync(pth, proto);
});
} catch(e) {}
}
function doit() {
const [ inf, outf ] = Deno.args;
if(inf == "-h" || inf == "--help") {
console.log(`usage: otorp.ts <path/to/bin> [output/folder]
if no output folder specified, log all discovered defs
if output folder specified, attempt to write defs in the folder
$ otorp.ts /Applications/Numbers.app out/ # search all files
$ otorp.ts /Applications/Numbers.app/Contents/MacOS/Numbers # search one file
`);
Deno.exit(1);
}
if(!inf || Deno.statSync(inf).isDirectory) (spin = new TerminalSpinner({ text: "", writer: Deno.stderr})).start();
if(outf) try { Deno.mkdirSync(outf, { recursive: true }); } catch(e) {}
process(inf, outf);
if(spin) spin.stop();
}
doit();

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "iwa-inspector",
"author": "sheetjs",
"private": true,
"homepage": "https://sheetjs.com/tools/iwa-inspector",
"license": "Apache-2.0",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"cfb": "1.2.2",
"printj": "1.3.1",
"react": "18.2.0",
"react-contexify": "6.0.0",
"react-dom": "18.2.0",
"react-inspector": "6.0.1",
"react-resizable-panels": "0.0.45",
"react-toastify": "9.1.3",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz"
},
"devDependencies": {
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",
"@vitejs/plugin-react": "4.0.0",
"eslint": "8.40.0",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.3.5",
"typescript": "5.0.4",
"vite": "4.3.5",
"vite-plugin-pwa": "0.14.7"
}
}

14533
public/protos Normal file

File diff suppressed because it is too large Load Diff

BIN
public/test.numbers Executable file

Binary file not shown.

52
src/App.css Normal file
View File

@ -0,0 +1,52 @@
html, body { width: 100vw; height: 100vh;}
#root {
width: 100vw; height: 100vh;
padding: 0;
text-align: center;
box-sizing: border-box;
display: block;
}
.App {
font-family: sans-serif;
text-align: left;
}
th {
background: white;
position: sticky;
top: 0;
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4);
}
.header {
top: 0;
width: 100vw;
max-width: 100%;
position: sticky;
box-shadow: 0 -5px 15px 0 #ced4da;
background: white;
z-index: 10;
}
.page {
width: calc(100vw - 10px);
height: calc(100vh - 35px) !important;
padding: 5px;
max-width: 100%;
display: flex;
flex-direction: column;
}
.left { text-align: left;}
.right { text-align: right;}
table { border-collapse: collapse;}
td, th { padding-left: 2px; padding-right: 2px; }
.overflow {
overflow: auto;
width: 100%;
height: 100%;
}

359
src/App.tsx Normal file
View File

@ -0,0 +1,359 @@
/* TODO:
- history
- find example and correctly handle "merge" messages
- messages referencing selected msg
- sort and filter table
- loading icons
- expand referenced object in place
- paste bytes -> analyze
- edit fields / files?
- different menu for message / enum / extend / literal
*/
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 } 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';
//#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 ( <pre>{data && xxd(data)}</pre> );
}*/
//<div className="proto"><Xxd data={id && file.space[+id]?.[0]?.data || void 0} /></div>
//#endregion
//#region TableView
type TableViewProps = {
id?: string;
data: any[];
cols: string[];
rowclick?: (row: any, R: number, e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>) => void;
cellclick?: (value: any, R: number, C: number, e: ReactMouseEvent<HTMLTableCellElement, MouseEvent>) => void;
};
function TableView({id, data, cols, rowclick, cellclick}: TableViewProps) {
return (
<table>
<thead>
<tr>{cols.map((c,idx) => (
<th key={idx}>{c}</th>
))}</tr>
</thead>
<tbody>{data.map((row, R) => (
<tr id={`tr-${R}`} key={R}
{...(rowclick ? {onClick: (e) => {e.preventDefault(); e.stopPropagation(); rowclick(row, R, e)}} : {})}
{...(row["id"] == id ? {style: {backgroundColor: "#646cff", color: "#FFFFFF" }} : {})}
>{/* TODO: forward-ref? */}
{cols.map((c,C) => (<td key={`${R}-${C}`}
{...(typeof row[c] == "string" ? {className: "left"} : {})}
{...(typeof row[c] == "number" ? {className: "right"} : {})}
{...(cellclick ? {onClick: (e) => {e.preventDefault(); e.stopPropagation(); cellclick(row, R, C, e)}} : {})}
>{row[c]??""}</td>))}
</tr>
))}</tbody>
</table>
);
}
//#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;
}
const ContextMenu = ({ID, menuType, menuField, menuId, onClickId, onClickCopyByteArray, onClickCopyJSON, showProtoDef}: ContextMenuProps) => (
<Menu id={ID}>
{menuField && (<Item disabled><b>{menuField}</b></Item>)}
<Item disabled><b>{menuType}</b></Item>
<Item hidden={()=>menuType != ".TSP.Reference"} onClick={onClickId}>Go to {menuId}</Item>
<Separator />
<Item onClick={onClickCopyByteArray}>Copy byte array</Item>
<Item onClick={onClickCopyJSON}>Copy JSON</Item>
<Item onClick={showProtoDef}>Show Definition</Item>
</Menu> );
//#endregion
function App() {
/* selected message ID */
const [id, setId] = useState<string>("0");
/* parsed file */
const [file, setFile] = useState<ParsedFile>({ space: {}, tbl: [], type: "N" });
/* current object */
const [obj, setObj] = useState<any>({});
/* current meta */
const [meta, setMeta] = useState<$_TSP_MessageInfo>({} as any);
/* protobuf definitions */
const [protos, setProtos] = useState<ProtoMap>({});
/* selected message type */
const [sel, setSel] = useState<string>("");
/* current protobuf definition */
const [__html, setProto] = useState<string>("Select a Row");
/* "dirty" if inspecting a subfield */
const [dirty, setDirty] = useState<boolean>(false);
/* history stack */
const [stack, setStack] = useState<string[]>([]);
/* react-contexify */
const MENU_ID = "insp-menu";
const { show } = useContextMenu({ id: MENU_ID });
const [menuField, setMenuField] = useState<string>("");
const [menuType, setMenuType] = useState<string>("");
const [menuId, setMenuId] = useState<string>("");
const tblRef = useRef<HTMLDivElement>(null);
/* update selection based on table row */
const doitRow = (row: any, R: number, reset?: boolean) => {
let obj: any, meta: $_TSP_MessageInfo;
try {
obj = process(file.space[+row.id][0].data, row.message, protos);
meta = process(file.space[+row.id][0].rawmeta, ".TSP.MessageInfo", protos);
} 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);
/* .TSP.MessageInfo */
if(meta.object_references) meta.$object_references = meta.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(meta.field_infos) meta.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;
});
})
setMeta(meta);
setId(String(row.id));
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);
}
};
/* 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);
};
/* on load, get protobuf definitions and process the test file */
useEffect(() => {
let ignore = false;
(async () => {
const protos = await (await fetch("protos")).text();
const testfile = await (await fetch("test.numbers")).arrayBuffer();
if(ignore) return;
setProtos(parse_protos(protos));
setFile(read(testfile));
})();
return () => { ignore = true; }
}, []);
useEffect(() => { if(file.tbl[0]) doitRow(file.tbl[0], 0, true); }, [file]);
const onChange: ChangeEventHandler<HTMLInputElement> = async (e: ChangeEvent<HTMLInputElement>) => {
if(!e.target.files) { toast.error("must select a file!"); throw new Error("No file selected!"); }
let data: ParsedFile;
try { data = read(await e.target.files?.[0].arrayBuffer()); } 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); return;
}
setFile(data);
}
/* 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); }
type NodeRendererProps = {
depth: number;
name: string;
data: any;
isNonenumerable: boolean;
expanded: boolean;
}
const nodeRenderer = ({ depth, name, data, isNonenumerable }: NodeRendererProps) => {
if(depth === 0) return ( <b>Message {id} <a onClick={()=>{selectProto(sel);}} onContextMenu={e => {displayMenu(e, { type: sel, id, data })}}><b>[{sel}]</b></a></b> );
if(typeof data == "bigint" && name.includes("identifier")) return ( <>
<ObjectName name={name} />: <ObjectValue object={data} /> -&gt; <a onClick={() => {gotoRef(String(data))}}><b>{String(data)}</b></a>
</> );
if(data.PB_TYPE) {
const frag = ( <a onClick={() => {selectProto(data.PB_TYPE);}}>
{data.PB_ENUM && <ObjectValue object={data.value} />}
<b>{data.PB_ENUM && <> = {data.PB_ENUM}</>} [{data.PB_TYPE}]</b>
</a> );
if(data.PB_TYPE == ".TSP.Reference") {
let id = String(data?.identifier);
return ( <span onContextMenu={(e) => {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}>
<ObjectName name={name} />: <b>{frag} -&gt; <a onClick={() => {gotoRef(id)}}><b>{id}</b></a></b>
</span> );
}
return ( <span onContextMenu={(e) => {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}>
<ObjectName name={name} />: {frag}
</span> );
}
return ( <ObjectLabel name={name} data={data} isNonenumerable={isNonenumerable} /> );
};
const metaRenderer = ({ depth, name, data, isNonenumerable }: NodeRendererProps) => {
if(depth === 0) return ( <b>Metadata</b> );
if(typeof data == "bigint" && name.includes("identifier")) return ( <>
<ObjectName name={name} />: <ObjectValue object={data} /> -&gt; <a onClick={() => {gotoRef(String(data))}}><b>{String(data)}</b></a>
</> );
if(data.PB_TYPE) {
const frag = ( <a onClick={() => {selectProto(data.PB_TYPE);}}>
{data.PB_ENUM && <ObjectValue object={data.value} />}
<b>{data.PB_ENUM && <> = {data.PB_ENUM}</>} [{data.PB_TYPE}]</b>
</a> );
if(data.PB_TYPE == ".TSP.Reference") {
let id = String(data?.identifier);
return ( <span onContextMenu={(e) => {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}>
<ObjectName name={name} />: <b>{frag} -&gt; <a onClick={() => {gotoRef(id)}}><b>{id}</b></a></b>
</span> );
}
return ( <span onContextMenu={(e) => {displayMenu(e, { type: data.PB_TYPE, id, data, field: data.PB_FIELD })}}>
<ObjectName name={name} />: {frag}
</span> );
}
return ( <ObjectLabel name={name} data={data} isNonenumerable={isNonenumerable} /> );
};
return ( <>
{/* header */}
<div id="header" className="header"><b><a href="https://sheetjs.com">SheetJS</a> IWA Inspector <input type="file" id="file" onChange={onChange} /></b></div>
<div className="page">
<PanelGroup direction='vertical'>
<Panel defaultSize={25}><div className="overflow" ref={tblRef}>
{/* message table */}
<TableView data={file.tbl} cols={["id", "type", "message", "path"]} id={id} rowclick={rowclick} />
</div></Panel>
<PanelResizeHandle style={{ height: "3px", backgroundColor: "#EEEEEE" }} />
<Panel><div className="overflow">
{/* selected message bar */}
<div style={{boxShadow: "0 2px 2px -1px rgba(0, 0, 0, 0.4)", height: "24px"}}>{sel ? (<>
<b>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 && <a onClick={pop}>Return to {stack[stack.length - 1]}</a> || ""}</b>
</>) : "Select a message to see the contents"}</div>
<div style={{width: "100%", height: "calc(100% - 24px)", overflow: "auto"}}><PanelGroup direction='horizontal'>
<Panel><div className='overflow'>
{/* proto definition */}
<pre style={{textAlign: "left"}}>
{dirty && (<><a onClick={() => { selectProto(sel);}}>Return to {sel}</a><br /><br /></>)}
{__html.split("\n").map((r, idx) => ( <>{!r.match(/(optional|repeated|required) \./) ? (
<span key={idx}>{r}</span>
) : (
<a key={idx} onClick={() => {selectProto(r.trim().split(" ")[1]);}}>{r}</a>
)}<br/></> ))}
</pre>
</div></Panel>
<PanelResizeHandle style={{ width: "2px", backgroundColor: "#EEEEEE" }} />
<Panel><div className="overflow" style={{textAlign: "left", marginTop: "13px", marginLeft: "10px", marginBottom: "13px"}}>
{/* inspector */}
<b>Message</b>
<ObjectInspector data={obj} expandLevel={1} nodeRenderer={nodeRenderer} />
<b>Meta</b>
<ObjectInspector data={meta} expandLevel={1} nodeRenderer={metaRenderer} />
<div style={{height:"13px"}}></div>
</div></Panel>
</PanelGroup></div>
</div></Panel>
</PanelGroup>
{/* Menu */}
<ContextMenu ID={MENU_ID} menuField={menuField} menuType={menuType} menuId={menuId} onClickId={onClickId} onClickCopyByteArray={onClickCopyByteArray} onClickCopyJSON={onClickCopyJSON} showProtoDef={showProtoDef} />
{/* Toast */}
<ToastContainer />
</div>
</> );
}
export default App

69
src/index.css Normal file
View File

@ -0,0 +1,69 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

411
src/iwa.ts Normal file
View File

@ -0,0 +1,411 @@
import * as CFB from 'cfb';
import Messages, { MessageTypes } from "./messages/";
/* see https://bugs.webkit.org/show_bug.cgi?id=243148 -- affects iOS Safari */
</