DEV Community

Cover image for Zed, Bun, Ghostty: Hard Mode Packaging on Android
Gouranga Das Samrat
Gouranga Das Samrat

Posted on

Zed, Bun, Ghostty: Hard Mode Packaging on Android

The hard tier is software that assumes a full desktop Linux box. GPU loaders, shared memory, process watchers, JIT engines, and dependency trees so big nobody on the upstream team has read all of them.

I maintain a good chunk of that tier for Termux. Here's what the patches look like, and what each one is really saying.

Bun: nine patches, three of them syscalls

Bun has the longest patch stack I deal with regularly. The filenames tell most of the story:

Patch What it fixes
0001 default to symlink install backend bun install uses symlinks by default
0002 remove Werror and unsupported flags Build stops failing on warnings and flags this toolchain lacks
0003 disable close_range Skip a syscall Android may refuse
0004 set termux tmpdir Temp files go to $PREFIX/tmp instead of /tmp
0005 fix bash completion empty regex Completion generation no longer chokes
0006 fix symlink bin chmod chmod on symlinked binaries stops failing
0007 disable pidfd_open Another filtered syscall, another fallback
0008 disable statx Same story
0009 android link rust as machine code Linking fix for the Rust-built part on Android

Read that list again. Three of nine are the same bug wearing different hats: the runtime probes a modern kernel feature, Android's filter objects. Upstream already carries fallbacks for old kernels, so the patch is mostly "pretend you're on an old kernel."

I like this class of fix because it's small and honest. It doesn't fork the project. It doesn't rewrite anything. It switches off a fast path and lets the existing slow path run.

Ghostty: GPU terminal, six patches

Ghostty is a good example of a project that makes zero Android assumptions, which is fair, it's a desktop terminal. Six patches got it running:

Patch Problem it solves
0001 dcimgui use desktop GL loader on android The UI layer picks the wrong GL loader for the target
0002 kitty graphics no shm_open on android Bionic has no shm_open, so the image protocol needs another path
0003 global skip xev io_uring probe on android The io_uring feature probe is a SIGSYS waiting to happen
0004 android default shell, home and /bin/sh Sensible defaults when there's no /bin/sh and no standard home
0005 os file android tmpdir Yes, /tmp again
0006 termio android process watcher without pidfd Watch child processes without pidfd

If you've read part one, you've already seen four of those problems. Only the GL loader is new. The same short list of Android quirks keeps showing up in completely unrelated codebases, and that's the main thing I've learned. After the first dozen ports, a failure stops being a mystery and becomes a checklist.

Zed: when the dependency tree is the problem

Zed is where the approach changes. The editor itself is fine. The trouble is in the crates underneath it. My recipe carries patches for alacritty_terminal, ipc-channel, trash, wasmtime-internal-jit-icache-coherence, wayland-cursor, x11rb-protocol and zed-scap, plus a workspace-level patch that wires them together.

Patching code that lives in other people's crates is where Rust packaging gets fiddly. The workable approach is to vendor the dependencies, patch the vendored sources, and tell Cargo to build from them:

# illustrative shape; real recipes differ per package
termux_step_pre_configure() {
    termux_setup_rust

    cd "$TERMUX_PKG_SRCDIR"
    cargo vendor --locked vendor > /dev/null

    mkdir -p .cargo
    cat >> .cargo/config.toml <<-EOF
        [source.crates-io]
        replace-with = "vendored-sources"

        [source.vendored-sources]
        directory = "vendor"
    EOF

    for diff in "$TERMUX_PKG_BUILDER_DIR"/*.vendor.diff; do
        patch -p1 < "$diff"
    done

    # Cargo verifies vendored files against recorded checksums.
    # Patched files fail that check, so clear the file list.
    for f in vendor/*/.cargo-checksum.json; do
        sed -i 's/"files":{[^}]*}/"files":{}/' "$f"
    done
}
Enter fullscreen mode Exit fullscreen mode

The checksum step is the one that eats an afternoon. You patch a vendored crate, run the build, and Cargo tells you a file changed. It's correct, you did change it. Emptying the files map in .cargo-checksum.json is how you tell it that's intentional.

Zed also ships a zed-shim.sh. I use shims when an app needs environment set up before it starts, and I'd rather wrap it than fork it:

#!@TERMUX_PREFIX@/bin/sh
# illustrative shape
export TMPDIR="${TMPDIR:-@TERMUX_PREFIX@/tmp}"
exec "@TERMUX_PREFIX@/opt/example/bin/example" "$@"
Enter fullscreen mode Exit fullscreen mode

Bruno has the same kind of shim, alongside two patches of its own: one to lazy-load node-pty, and one so the machine-id lookup doesn't try to execute anything.

Browser automation on a phone

Playwright is the one that makes people do a double take. Browser automation needs a browser, a driver, and a runtime that agree with each other. The recipe has android-support.patch, two Firefox patches (firefox-support and firefox-single-window), and a path-matching fix. The Python side, python-playwright, has a use-system-driver.patch, so it talks to the driver installed on the system instead of the one bundled in the wheel.

That bundled driver is a good example of a general rule: anything that ships its own prebuilt binary is a candidate for "swap it for the system one."

The small stuff matters too

Not every patch is dramatic. Some of my favorites are tiny:

  • numbat disables Android plotting support, because the plotting backend has no business running here.
  • pnpm builds sha2 without asm.
  • uutils-coreutils carries fixes for 32-bit devices: an auxv fix, a 32-bit stat mode fix in tail, and a small syncfs.c. A lot of phones are still 32-bit, and nobody tests for them.
  • thonny needed four patches around mypy, shell detection and path opening.
  • albert has six patches for its XDG handling, system utilities and plugin loading.

What I'd tell anyone starting

Read the failure literally. ENOENT is a path problem. SIGSYS is a syscall problem. A missing symbol is a Bionic gap. Most of the work is classifying the failure correctly.

Prefer small, guarded patches. Wrap the change in __ANDROID__ or the language equivalent, and keep it narrow. Those are patches upstream might actually accept.

Shim before you fork. An env-setting wrapper script is easier to maintain than a diverged repository.

And be honest about the cost. Every version bump is a rebase. Nine patches on Bun means nine things that can break on the next release. That's the part nobody writes blog posts about, and it's most of the job.

Where to find it

Everything I maintain is tracked on Repology, and I'm on GitHub if you want to see the work, open an issue, or tell me which package you'd like to see next.

Termux has limits. I just don't agree with where most people think they are.

Top comments (0)