About Me Projects Previous Work

bonk

All information on this page has been shared with explicit permission from Traverse Research

bonk is a build tool written in Rust, for Rust projects using Traverse's custom rendering framework, with support for cross-compiling and publishing to all major platforms and stores. It was developed during my internship at Traverse Research.

The goal is to hide all the platform-specific tooling: Android SDK, iOS codesigning, MSIX (MS-Store) packaging, AppImage/Flatpak creation. Bonk handles platform-specific details, so that building and deploying to any target is a single command regardless of the host machine.

$ bonk devices
host                                         Linux          linux x64      Arch Linux 5.16.10-arch1-1
adb:16ee50bc                                 FP4            android arm64  Android 11 (API 30)
imd:55abbd4b70af4353bdea2595bbddcac4a2b7891a David's iPhone ios arm64     iPhone OS 15.3.1

$ bonk build --device adb:16ee50bc
[1/3] Fetch precompiled artifacts [72ms]
[2/3] Build rust [143ms]
[3/3] Create apk [958ms]

Device Abstraction

The core of bonk's cross-platform support is the device abstraction. Every target device (Android phone, iPhone, Linux host) implements the same interface through a backend enum.

#[derive(Clone, Debug)]
enum Backend {
    Adb(Adb),
    IDevice(IDevice),
    Imd(IMobileDevice),
    Host(Host),
}

pub struct Device {
    backend: Backend,
    id: String,
}

impl Device {
    pub fn list() -> Result<Vec<Self>> {
        let mut devices = vec![Self::host()];
        if let Ok(adb) = Adb::new() {
            adb.devices(&mut devices)?;
        }
        if let Ok(imd) = IMobileDevice::which() {
            imd.devices(&mut devices).ok();
        }
        if let Ok(idevice) = IDevice::new() {
            idevice.devices(&mut devices)?;
        }
        Ok(devices)
    }

    pub fn platform(&self) -> Result<Platform> {
        match &self.backend {
            Backend::Adb(adb)         => adb.platform(&self.id),
            Backend::Host(host)       => host.platform(),
            Backend::Imd(imd)         => imd.platform(&self.id),
            Backend::IDevice(idevice) => idevice.platform(&self.id),
        }
    }
}

The enum approach keeps bonk's code clean and easy to maintain. Adding a new device backend means adding one variant and implementing the small set of methods. Everything else works without changes.

Modular Packaging

Each packaging format lives in its own crate: apk for Android, appbundle for iOS, appimage for Linux, msix for Windows, and flat-package for macOS. This, once again, keeps the codebase clean and easy to maintain and extend with more supported formats and platforms.

bonk coordinates them: it compiles for the right target triple (derived from the platform and arch), fetches any missing SDKs through the download manager (so users don't need to have SDKs available on their machine for bonk to work), then invokes whichever packaging format matches the device.

// Each platform packager is a separate crate in the workspace
// apk/      appbundle/      appimage/
// msix/     flat-package/   mvn/

// The build command resolves the packager from the device's platform
match device.platform()? {
    Platform::Android => apk::build(&env, &artifact, &config)?,
    Platform::Ios     => appbundle::build(&env, &artifact, &config)?,
    Platform::Linux   => appimage::build(&env, &artifact, &config)?,
    Platform::Windows => msix::build(&env, &artifact, &config)?,
    Platform::MacOs   => flat_package::build(&env, &artifact, &config)?,
    Platform::Host    => { /* no packaging needed */ }
}

Auto-Updater

Every bonk instance silently checks GitHub for available updates (unless explicitly disabled, and at most once per hour, so it never slows down the build). Authentication uses the GitHub App OAuth device flow: bonk prints a short code and a URL, the user approves it in a browser, and the token is stored and refreshed automatically, staying valid for up to 6 months.

When an update is found, bonk prompts inline, before running the desired command, with three choices: apply, skip, or view the changelog before making a decision. Saying no offers to ignore that version until the next one.

$ bonk build --device host
Update available: v0.1.11 -> v0.1.12
Would you like to apply it [Y/n/v (View Changelog)]: v

## v0.1.12
- Fix adb device detection on Android 14
- Add Epic Games Store publishing support

Would you like to apply it [Y/n/v (View Changelog)]: y
Updating bonk in cargo root `/home/tudor/.cargo`
   Compiling bonk v0.1.12 ...
Update successful! Press <Return> to continue

On Windows, a running executable cannot be replaced. To work around this, bonk copies itself to a temporary update-bonk, and spawns that copy to run the actual cargo install. This way, the original binary is no longer held open when it gets overwritten.

The only disadvantage is that this detatches from the terminal, so the user could theoreticall close it while bonk is still updating, but the updater would still run in the background.

if want_update {
    // On Windows, a running executable can't be replaced.
    // Copy bonk to a temp name and spawn the copy to do the install.
    let temp_updater = cargo_root.join(format!("bin/{}", exe!("update-bonk")));
    std::fs::copy(
        cargo_root.join(format!("bin/{}", exe!("bonk"))),
        &temp_updater,
    )?;
    std::process::Command::new(temp_updater)
        .args(["--run-update-version", &latest.version_number])
        .spawn()?;
}

Interactive Manifest Browser

bonk projects are configured through a manifest file. Rather than documenting a largo magic config file, bonk manifest opens an interactive terminal interface generated directly from the Rust config types using schemars, where the user can browse through the options in the manifest.

At startup, bonk derives a full JSON schema from RawConfig. The manifest command then walks that schema recursively, resolving $ref pointers, unwrapping anyOf/oneOf variants, and detecting optional fields. It builds a navigable tree from it, where users can see the schema and description of each config section.

// Derive a full JSON schema from the config types at compile time
let full_schema = schemars::schema_for!(RawConfig).to_value();

// Walk the schema and build an interactive tree
let mut renderer = TreeRenderer::<ManifestProperty>::default();
generate_ui(&mut renderer.tree, &full_schema, root_node, &full_schema, "the manifest")?;

// Run the interactive terminal UI
renderer.run()?;

Each node in the tree is a ManifestProperty: a field name, its type, and its description, all pulled from the schema. Navigating to (hovering over) a property expands it to show type and description without leaving the terminal. Because the UI is generated directly from the manifest struct, it always stays in sync with the actual config format. No need for intervention from maintainers.

// The schema introspection trait — extracts type info from raw JSON schema nodes
impl CheckItemType for serde_json::Value {
    fn try_get_type(&self) -> Result<Option<ItemType>> {
        if self.get("oneOf").is_some_and(|t| t.is_array()) {
            Ok(Some(ItemType::OneOf))
        } else if let Some(ty) = self.get("type") {
            // Distinguish plain types, arrays, and optional (null | T) fields
            if ty.is_array() {
                return try_any_of_or_optional(/* ... */);
            }
            Ok(Some(ItemType::Type(ty.as_str().unwrap().to_string())))
        } else if let Some(sref) = self.get("$ref") {
            // Resolve references like "#/$defs/AndroidConfig" to the type name
            Ok(Some(ItemType::Type(
                sref.as_str().unwrap().split('/').next_back().unwrap().to_string()
            )))
        } else {
            Ok(None)
        }
    }
}

Multi-Store Publishing

Bonk can also publish directly to Google Play, Apple App Store, Steam, Microsoft Store, Epic Games Store, and Vivo (and more coming). Each store is a subcommand: bonk publish steam, bonk publish apple, bonk publish play-store, and so on.

Credentials can be passed three ways: a raw string, file contents, or an environment variable. The same PublishToken type handles all three, so CI pipelines and local setups both can use the same manifest for publishing.

# Inline token
bonk publish steam --login "actual_token_value"

# Read from a file (useful for secrets stored on disk)
bonk publish steam --login "file:/path/to/token.txt"

# Read from an environment variable (standard for CI)
bonk publish steam --login "env:STEAM_TOKEN"

Under the hood, stores split into two publishing strategies. Steam, Apple, Epic, and Microsoft call external CLI tools (steamcmd, Apple Transporter, etc.) with the right arguments generated from the generic bonk arguments. Google Play and Vivo use native Rust API clients through the store's REST API, so they don't depend on any extra tools to be pulled by bonk's downloader.

Diagnostics

Getting cross-compilation toolchains set up is notoriously painful. bonk doctor checks every required external tool and reports versions, making it easy for users to know what tools they might be missing before running builds or debugging.

$ bonk doctor
--------------------clang/llvm toolchain--------------------
clang                14.0.6     /usr/bin/clang
clang++              14.0.6     /usr/bin/clang++
lld                  14.0.6     /usr/bin/lld

----------------------------rust----------------------------
rustup               1.25.1     /usr/bin/rustup
cargo                1.64.0     /usr/bin/cargo

--------------------------android---------------------------
adb                  1.0.41     /usr/bin/adb
javac                11.0.17    /usr/bin/javac
gradle               7.5.1      /usr/bin/gradle

----------------------------ios-----------------------------
idevice_id           1.3.0      /usr/bin/idevice_id
ideviceinstaller     1.1.1      /usr/bin/ideviceinstaller