Skip to content
Merged
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
124 changes: 124 additions & 0 deletions FIX_FOR_CLAUDE_CODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Fix: source/split editor still crashes after #1 — Highlightr `Bundle.module` lookup

> **Claude Code–ready.** Open this repo in Claude Code and say:
> *"Implement the fix in FIX_FOR_CLAUDE_CODE.md and prove it with the regression test."*
> Everything needed to reproduce, fix, and verify is below.

## Status

PR #1 (*"bundle Highlightr SPM resources"*, merged) copies `Highlightr_Highlightr.bundle`
into `Contents/Resources/`. That is **necessary but not sufficient** — the app still hard-crashes
(`SIGTRAP`) the instant the **source/split editor** renders, because the SwiftPM-generated
`Bundle.module` accessor never looks in `Contents/Resources`.

## Symptom

- Preview-only use looks fine (the editor `NSView` is never instantiated).
- Switching to **Source (⌘1) / Split (⌘2)**, or restoring a window that had the editor open, crashes immediately.
- `EXC_BREAKPOINT (SIGTRAP)` — a Swift `fatalError`.

Top of the crash stack:

```
_assertionFailure
closure #1 … static Bundle.module
Highlightr.init(highlightPath:) // src/classes/Highlightr.swift:56 → let bundle = Bundle.module
SyntaxHighlighter.applyHighlights(…)
MarkdownTextEditor.makeNSView(context:)
```

`Highlightr.init` calls `Bundle.module` **unconditionally** (line 56) before it looks at
`highlightPath`, so passing a path cannot avoid it.

## Reproduce

```bash
printf '# t\n```swift\nlet x = 1\n```\n' > /tmp/t.md
open -a /Applications/CmdMD.app /tmp/t.md
osascript -e 'tell application "CmdMD" to activate' \
-e 'tell application "System Events" to keystroke "1" using command down' # Source view
# → process dies; a new ~/Library/Logs/DiagnosticReports/CmdMD-*.ips appears
```

## Root cause

The toolchain emits the two-path accessor in
`.build/…/Highlightr.build/DerivedSources/resource_bundle_accessor.swift`:

```swift
let mainPath = Bundle.main.bundleURL.appendingPathComponent("Highlightr_Highlightr.bundle").path
let buildPath = "/Users/runner/work/CmdMD/CmdMD/.build/arm64-apple-macosx/release/Highlightr_Highlightr.bundle"
guard let bundle = Bundle(path: mainPath) ?? Bundle(path: buildPath) else { Swift.fatalError(…) }
```

For a packaged `.app`, `Bundle.main.bundleURL` is the **`.app` root**, therefore:

- `mainPath` = `…/CmdMD.app/Highlightr_Highlightr.bundle` — the app root, **outside `Contents/`**.
macOS code signing forbids resources there (`codesign: unsealed contents present in the bundle root`),
so the bundle can never legally live at this path in a signed app.
- `buildPath` = the CI's `.build` directory, which does not exist on a user's machine.

Both candidates fail → `fatalError`. The accessor **never checks `Bundle.main.resourceURL`**
(= `Contents/Resources`), which is exactly where #1 put the bundle. That is why the crash persists.

Evidence — a minimal `.app` with the same layout prints:

```
Bundle.main.bundleURL = …/X.app
accessor mainPath = …/X.app/Highlightr_Highlightr.bundle ← app root (unsignable)
```

## Fix

Two options. **(A)** is verified and minimal; **(B)** is the clean, location-independent long-term fix.

### (A) Verified, minimal — repoint the dead `buildPath` fallback (this PR)

The accessor falls back to `Bundle(path: buildPath)`. Rewrite `buildPath` *in the built binary*
to the bundle #1 already ships in `Contents/Resources`, **before** codesign re-seals it.

`scripts/fix-highlightr-bundle.py` (included) does an in-place, equal-length, null-padded
string replacement (Mach-O offsets preserved):

```
old (any builder): …/.build/<arch>/release/Highlightr_Highlightr.bundle
new: /Applications/CmdMD.app/Contents/Resources/Highlightr_Highlightr.bundle
```

`package_app.sh` runs it right after the executable + bundles are staged and just before
`codesign --force --deep --sign -`. Verified on an installed `1.4.7`: Source / Split / Preview
all render, **zero** new crash reports.

**Caveat:** this assumes the documented `/Applications` install location (the README already
instructs dragging to `/Applications`). An `.app` run from elsewhere still won't resolve — use (B)
for full independence.

### (B) Robust, recommended — build the `.app` with Xcode / `xcodebuild`

An Xcode app target emits the multi-candidate resource accessor (it checks
`Bundle.main.resourceURL`), so the `Contents/Resources` bundle from #1 resolves with no patching
and no path assumptions. Replace `swift build` + manual `package_app.sh` staging with an
`xcodebuild` app target.

## Regression test (acceptance criteria)

A fix is complete only when this prints `PASS` and produces **no** new crash report, **and** a code
block visibly renders with syntax colors in the editor (proves the bundle actually loaded — not just
"did not crash"):

```bash
printf '# t\n```swift\nlet x = 1\n```\n' > /tmp/t.md
open -a /Applications/CmdMD.app /tmp/t.md
osascript -e 'tell application "CmdMD" to activate' \
-e 'tell application "System Events" to keystroke "1" using command down' \
-e 'tell application "System Events" to keystroke "2" using command down'
sleep 3
pgrep -x CmdMD >/dev/null && echo PASS || echo FAIL
```

## Verified here

- Reproduced on `1.4.6 (12)` and `1.4.7 (13)`, macOS 26.5.1, Apple Silicon.
- Confirmed the bundle is present in `Contents/Resources` yet `Bundle.module` still fails — so #1 alone is insufficient.
- Applied (A) to an installed `1.4.7`: Source / Split / Preview render, **0** new crash reports.
- (B) is recommended but not built/verified here.
61 changes: 61 additions & 0 deletions scripts/fix-highlightr-bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Repoint Highlightr's Bundle.module fallback path inside a built CmdMD binary.

The SwiftPM-generated accessor resolves the resource bundle as:

Bundle(path: Bundle.main.bundleURL + "Highlightr_Highlightr.bundle") # = .app ROOT (unsignable)
?? Bundle(path: "<...>/.build/<arch>/release/Highlightr_Highlightr.bundle") # = CI path (absent for users)

In a packaged .app both candidates fail, so Highlightr.init() hits fatalError the moment the
editor renders. This patches the second (baked) string to the bundle that package_app.sh ships in
Contents/Resources, so the fallback resolves at runtime.

It is an in-place, equal-length, NUL-padded replacement, so all Mach-O offsets are preserved.
Run it BEFORE codesign; the subsequent `codesign --force --deep --sign -` re-seals the binary.

usage: fix-highlightr-bundle.py <path/to/CmdMD.app/Contents/MacOS/CmdMD>

NOTE: the replacement path assumes the documented /Applications install location. For full
install-location independence, build the .app with an Xcode/xcodebuild app target instead (its
resource accessor checks Bundle.main.resourceURL, i.e. Contents/Resources).
"""
import sys

NEW = b"/Applications/CmdMD.app/Contents/Resources/Highlightr_Highlightr.bundle"
NEEDLE = b"Highlightr_Highlightr.bundle"


def find_buildpath(data: bytearray):
"""Return (start, end) of the baked '<...>/.build/.../Highlightr_Highlightr.bundle' C string."""
pos = 0
while True:
i = data.find(NEEDLE, pos)
if i == -1:
return None
start = data.rfind(b"\x00", 0, i) + 1 # C string begins after the previous NUL
end = data.find(b"\x00", i)
cstr = bytes(data[start:end])
# the build-path literal contains ".build/"; the bare-name literal used by mainPath does not
if b"/.build/" in cstr and cstr.endswith(NEEDLE):
return start, end
pos = i + len(NEEDLE)


def main(bin_path: str) -> None:
data = bytearray(open(bin_path, "rb").read())
hit = find_buildpath(data)
if hit is None:
sys.exit("buildPath string not found (already patched or unexpected build layout)")
start, end = hit
old_len = end - start
if len(NEW) > old_len:
sys.exit(f"replacement path ({len(NEW)}B) longer than original ({old_len}B); cannot patch in place")
data[start:end] = NEW + b"\x00" * (old_len - len(NEW))
open(bin_path, "wb").write(data)
print(f"patched buildPath @ offset {start} -> {NEW.decode()}")


if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
main(sys.argv[1])
14 changes: 14 additions & 0 deletions scripts/package_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ for bundle in "${resource_bundles[@]}"; do
cp -R "$bundle" "$RESOURCES_DIR/"
done

# The copy above is necessary but NOT sufficient. With `swift build`, Highlightr's
# generated `Bundle.module` accessor resolves the bundle from `Bundle.main.bundleURL`
# (the .app ROOT, where code signing forbids resources) and from a baked `.build` path
# (absent on user machines) — it never checks Contents/Resources. So the app still traps
# on the first code-block highlight (editor render). Repoint the baked fallback path to
# the shipped Contents/Resources bundle, before codesign re-seals the binary.
# See FIX_FOR_CLAUDE_CODE.md. Long-term fix: build via an Xcode/xcodebuild app target.
if command -v python3 >/dev/null 2>&1; then
python3 "$(dirname "$0")/fix-highlightr-bundle.py" "$EXECUTABLE" \
|| echo "Warning: Highlightr bundle-path patch failed; editor view may still crash." >&2
else
echo "Warning: python3 not found; skipping Highlightr bundle-path patch." >&2
fi

if [[ -f "$APP_ICON" ]]; then
cp "$APP_ICON" "$RESOURCES_DIR/AppIcon.icns"
else
Expand Down