bunmaska build compiles your app with bun build --compile, bundles it next to the Bun runtime, and emits native distributables. There’s no Chromium to ship, so the outputs are small and the build is fast - no Xcode project, no electron-builder.
Build your app
From your project root:
bunmaska build
The entry comes from your bunmaska.config.ts (pass it explicitly - bunmaska build src/main.ts - if you prefer). By default this targets the operating system you run it on; --target macos|linux|windows cross-builds from another host (a macOS .app still needs a macOS host for codesign). A build is “your compiled code + the Bun runtime” - on macOS and Linux there is no engine to ship at all.
macOS
bunmaska build # > MyApp.app
bunmaska build --dmg # > MyApp.app + MyApp.dmg
You get:
.appbundle - named from--name, elsenameinbunmaska.config.ts, else the entry file’s stem - with a.icnsicon converted from your PNG..dmg- the drag-to-Applications disk image.- Code signing & notarization (optional but recommended for distribution):
# Sign with your Developer ID, then notarize with Apple
APPLE_ID="[email protected]" \
TEAM_ID="XXXXXXXXXX" \
BUNMASKA_NOTARIZE_PASSWORD="app-specific-password" \
bunmaska build --sign "Developer ID Application: Your Name (TEAMID)" --notarize
--notarize requires --sign. It zips the signed .app, submits it via xcrun notarytool --wait, and staples the ticket. If the three env vars above are missing it is skipped with a message telling you which ones to set, rather than failing the build. A signed + notarized app passes macOS Gatekeeper without a warning. (Requires an Apple Developer account - $99/yr, one account, unlimited apps.)
Architectures
Build on the architecture you’re targeting: an Apple Silicon Mac produces arm64, an Intel Mac produces x64. To target the other arch, build on a machine (or CI runner) of that arch.
Linux
bunmaska build # > <Name>-linux-<arch>.tar.gz + <slug>_<version>_<arch>.deb
You get:
<Name>-linux-<arch>.tar.gz- a relocatable AppDir bundle.<slug>_<app-version>_<arch>.deb- the app version comes from your project’spackage.json(0.0.0if absent); for Debian/Ubuntu and derivatives (theararchive is assembled in pure JS - nodpkgtoolchain required to produce it).
The generated
.debdepends on exactlylibwebkitgtk-6.0-4andlibgtk-4-1, so a user’sapt installpulls the engine in automatically - you don’t ship it, and they don’t hunt for it. Bunmaska never bundles a browser.
Architectures
The build targets the host architecture: an x64 box produces x64, an ARM64 box (including a Raspberry Pi) produces arm64. The same command, no cross-compile gymnastics.
Windows
bunmaska build --target windows compiles a self-contained .exe into a portable <Name>/ directory and zips it (x64). Because Windows ships no system WebKit, the app needs a WinCairo WebKit2.dll - install the hosted one from the feed (bunmaska engine install <id>) and pin it, embed a build (--embed-engine <dir>), or point BUNMASKA_WEBKIT_PATH at one. The stable-train build and ARM64 are next. See Platform Support and the roadmap.
The engine pin
engine.webkit in bunmaska.config.ts is baked into Linux and Windows bundles, so the app resolves that engine at launch. On macOS it is not baked - macOS always runs the system WKWebView today; pinning there is on the roadmap. A bare upstream version like 2.52.4 is not resolved yet: build bakes system and warns, so pin a full engine id (see Pinned WebKit Engine).
Bundling your renderer
If your renderer is more than a static index.html - React, TypeScript, anything that needs a bundler - Bunmaska can own that build. Declare it in bunmaska.config.ts:
import { defineConfig } from 'bunmaska/config';
export default defineConfig({
name: 'MyApp',
entry: 'src/main.ts',
renderer: {
entry: 'src/renderer/main.tsx', // your renderer entry
outDir: 'dist/renderer', // default: dist/renderer
copy: ['src/renderer/index.html'], // static files copied in verbatim
},
});
With that block in place:
bunmaska devbuilds the renderer up front, then rebuilds and live-reloads the open windows when you edit anything under the renderer entry’s directory - a React component edit no longer restarts the whole app. A broken edit prints the bundler error and keeps the dev loop alive.bunmaska buildbuilds the renderer first and ships the output as arenderer/directory beside the executable, on all three platforms. PointloadFileat it the same way the scaffold resolves its assets (next to the entry in dev, next toprocess.execPathwhen packaged).
Why the output looks the way it does
Two deliberate choices, both forced by how a desktop renderer actually loads. If you bring your own bundler instead, you will need the same two settings:
- A classic IIFE bundle, not ES modules.
loadFileserves your page overfile://, and browsers treatfile://as a null origin - a<script type="module">fails the CORS check and silently loads nothing. A classic script has no such rule, so the bundle is one IIFE yourindex.htmlincludes with a plain<script src>. NODE_ENVfollows the command.bunmaska devdefinesprocess.env.NODE_ENVasdevelopment(the React development runtime, warnings andjsxDEV);bunmaska builddefinesproduction. Bun picks the matching JSX runtime from that define, so areact-jsxtsconfig needs nothing else.
The renderer block is optional: apps with their own bundler setup (Vite, etc.) just keep running it themselves and let bunmaska dev live-reload on the output writes - dist/ is watched.
Auto-updates
bunmaska build can emit a signed update feed, and the runtime autoUpdater consumes it. The whole pipeline is four steps.
1. Generate a signing key pair (once):
bunmaska keygen
This writes update-signing-key.pem (private - signs releases, never ships in the app; keep it out of git) and update-public-key.pem (bake it into your app).
2. Build with --update and sign the artifact:
bunmaska build --update --update-key update-signing-key.pem --channel stable
This writes a content-hashed <name>-<channel>-<os>-<arch>.tar.zst, an update.json manifest (its version is read from your project’s package.json), and a detached .sig alongside your build. Skipping --update-key prints a loud warning and produces an unsigned feed the runtime autoUpdater will refuse - fine for a smoke test, useless for shipping. Because there’s no 150 MB engine inside, update payloads are tiny: users download your code, not a browser.
3. Host the three files (update.json, the .tar.zst, the .sig) in one https directory - any static host or object store works.
4. Wire it up in your app, with the public key baked in:
import { autoUpdater } from "bunmaska";
autoUpdater.setFeedURL({
url: "https://downloads.example.com/myapp/stable",
publicKey: UPDATE_PUBLIC_KEY_PEM, // contents of update-public-key.pem
});
autoUpdater.on("update-available", () => autoUpdater.downloadUpdate());
autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall());
autoUpdater.checkForUpdates();
downloadUpdate verifies size, content hash, and the Ed25519 signature before staging; quitAndInstall performs a real swap - a detached helper waits for the app to exit, extracts into a temp sibling, rename-swaps the bundle into place, and relaunches. See the autoUpdater reference for the full contract and its honest caveats (it refuses to swap a non-installed layout, and the live swap is the one step CI doesn’t exercise end to end).
A note on signing & trust
For a distribution users will trust on first launch:
- macOS: Developer ID signing + notarization (above).
- Linux: ship the
.debfrom a repo or a signed release; users get the engine from their package manager.