Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,31 @@ created by older versions of `keynote-parser`:

As `keynote-parser` includes Protobuf definitions extracted from a copy of Keynote,
new versions of Keynote will inevitably create `.key` files that cannot be read by `keynote-parser`.
As new versions of Keynote are released, updates to `keynote-parser` can be made automatically
by running the following on a macOS machine with Keynote installed:
When a new version of Keynote is installed, run the following on that macOS machine to regenerate
the mappings and compiled Protobuf files:

```shell
cd dumper
make clean
make
PYTHONPATH=$PYTHONPATH:$(pwd) uv run --python /opt/homebrew/bin/python3 dumper/run.py --app-path "/Applications/Keynote.app"
```

**Prerequisites:**
- macOS with Keynote installed
- [Homebrew](https://brew.sh) Python 3.13: `brew install python@3.13`
- [LLVM/LLDB](https://llvm.org) matching that Python version: `brew install llvm`
- `protoc`: `brew install protobuf`

**Notes:**
- The app path may differ depending on the Keynote version installed
(e.g. `/Applications/Keynote 2025.app`). Check your `/Applications` folder.
- `uv run --python /opt/homebrew/bin/python3` is required because the LLDB Python bindings
must match the Homebrew Python that LLDB was compiled against. Using `uv`'s bundled Python
will cause a silent crash.
- The script will briefly launch Keynote under the debugger to extract the type registry.
Keynote may appear on screen momentarily — this is expected.
- No codesigning certificate is required; the script uses ad-hoc signing (`-`) automatically.
- The generated files (`keynote_parser/versions/v*/generated/`) are not committed to the
repository and must be regenerated locally after cloning.

## Troubleshooting

### Unable to complete installation due to snappy-c.h not found.
Expand Down
25 changes: 13 additions & 12 deletions dumper/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,19 @@
def unsigned_copy_of(app_path: str) -> Generator[str, None, None]:
app_name = os.path.basename(app_path).replace(".app", "")
unsigned_app_bundle_filename = f"{app_name}.unsigned.app"
# The executable name may differ from the bundle name (e.g. "Keynote 2025.app" contains "Keynote")
exe_name = plistlib.load(
open(os.path.join(app_path, "Contents", "Info.plist"), "rb")
)["CFBundleExecutable"]

# Get the identity from the system:
# Get the identity from the system, falling back to ad-hoc signing ("-")
# which requires no certificate and is sufficient for LLDB to attach.
logging.info("Getting codesigning identity...")
identity = subprocess.check_output(
identity_output = subprocess.check_output(
["security", "find-identity", "-v", "-p", "codesigning"]
).decode()
identity = identity.split('"')[1]
if not identity:
raise ValueError(
"No codesigning identity found; please create one in Keychain Access first."
)
logging.info(f"Resigning {app_path} with local codesigning identity: {identity!r}")
identity = identity_output.split('"')[1] if '"' in identity_output else "-"
logging.info(f"Resigning {app_path} with codesigning identity: {identity!r}")

with tempfile.TemporaryDirectory() as temp_dir:
target = os.path.join(temp_dir, unsigned_app_bundle_filename)
Expand All @@ -63,20 +64,20 @@ def unsigned_copy_of(app_path: str) -> Generator[str, None, None]:
"codesign",
"--remove-signature",
"--verbose",
os.path.join(target, "Contents", "MacOS", app_name),
os.path.join(target, "Contents", "MacOS", exe_name),
]
)
# Resign the app with the local identity:
# Resign the app with the local identity (or ad-hoc if none available):
logging.info(
f"Resigning {target} with local codesigning identity: {identity!r}"
f"Resigning {target} with codesigning identity: {identity!r}"
)
subprocess.run(
[
"codesign",
"--sign",
identity,
"--verbose",
os.path.join(target, "Contents", "MacOS", app_name),
os.path.join(target, "Contents", "MacOS", exe_name),
]
)
logging.info(f"Successfully re-signed {target}.")
Expand Down
9 changes: 8 additions & 1 deletion keynote_parser/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import snappy
import yaml
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf.internal.decoder import _DecodeVarint32
from google.protobuf.internal.encoder import _VarintBytes
from google.protobuf.json_format import MessageToDict, ParseDict
Expand Down Expand Up @@ -170,7 +171,13 @@ def FromString(cls, message_info, proto_klass, data, version: str = LATEST_VERSI
"Message info was:\n%s\nObject was:\n%s" % (message_info, data)
)
for diff_path in message_info.diff_field_path.path:
patched_field = proto_klass.DESCRIPTOR.fields_by_number[diff_path]
if diff_path in proto_klass.DESCRIPTOR.fields_by_number:
patched_field = proto_klass.DESCRIPTOR.fields_by_number[diff_path]
else:
# Extension fields (proto2 `extend` blocks) are not in fields_by_number
patched_field = _descriptor_pool.Default().FindExtensionByNumber(
proto_klass.DESCRIPTOR, diff_path
)
field_message_class = import_version(version)[1][
patched_field.message_type.full_name
]
Expand Down