BunKit
Write a Mac app in TypeScript on Bun, with real AppKit windows and controls rather than a web page.
BunKit reaches AppKit through a small native library that can call any Objective-C
method, and it shares the main thread between AppKit's event loop and Bun's, so timers,
fetch and buttons all work together. The
bridge covers that layer and Metal covers the GPU.
BunKit 0.1.0 runs on macOS 26 on Apple silicon, and refuses to start on an Intel Mac.
Setting up
You need Bun 1.4 or newer and the Xcode command line tools (xcode-select
--install). BunKit is not on npm, because its native library is compiled on your
machine:
git clone https://github.com/scarletindustries/bunkit
cd bunkit
bun install
./native/build.sh
Run ./native/build.sh again whenever anything under native/
changes. The repository has 8 example apps:
bun run hello # a text field, a button and a label
bun run tour # a task list: table, menu, sheet and the escape hatch
bun run demo # table, detail form, control gallery, log pane, live clock
bun run scene # a 3D scene
bun run rig # a stage lighting rig to fly around
bun run playground # a live shader editor
bun run particles # 250,000 particles simulated on the GPU
bun examples/raw-objc.ts # the hello window written against the bridge alone
A first app
This is the whole of examples/hello.ts:
import { Application, Button, HStack, Label, TextField, VStack, Window } from "bunkit";
const app = new Application({ name: "Hello" });
const name = new TextField({ placeholder: "Your name", grow: 1 });
const greeting = new Label({ text: "Type a name and press Greet.", color: "secondaryLabel" });
const greet = () => {
greeting.text = name.value ? `Hello, ${name.value}!` : "Type a name first.";
};
new Window({
title: "Hello",
size: { width: 380, height: 160 },
content: new VStack({ spacing: 14, padding: 20 }, [
new Label({ text: "Greeter", font: { style: "title", weight: "semibold" } }),
new HStack({ spacing: 8 }, [
name,
new Button({ title: "Greet", primary: true, onClick: greet }),
]),
greeting,
]),
}).quitOnClose();
await app.run();
Controls are ordinary objects that you keep and change directly, so assigning
greeting.text updates the label at once, with no render pass.
await app.run() ends the process when the app quits, so put shutdown work in
an onQuit handler rather than after it.
Windows
Every option is optional, and a window's properties can be read and set after it opens.
Returning false from shouldClose keeps the window open.
const win = new Window({
title: "Notes",
size: { width: 640, height: 420 },
minSize: { width: 480, height: 320 },
autosaveName: "MainWindow",
onResize: (size) => (dimensions.text = `${size.width} x ${size.height}`),
shouldClose: () => dirty === false,
});
win.title = "Notes (edited)";
Layout
A VStack lays its children out in a column and an HStack in a
row. A view takes spare space only when it sets grow.
new VStack({ spacing: 12, padding: 16 }, [
new HStack({ spacing: 8 }, [
new TextField({ placeholder: "New task…", grow: 1 }),
new Button({ title: "Add", primary: true, onClick: add }),
]),
table, // grow: 1 in its own options
new HStack({ spacing: 8, align: "center" }, [
status,
new Spacer(),
new Button({ title: "Clear…", destructive: true, onClick: clear }),
]),
])
When a layout asks for more room than the window has, Auto Layout quietly breaks a rule
and a view draws outside its box. checkLayout(win) returns every view that
does, so a test can expect an empty list.
checkLayout(win); // [] when the layout holds
// -> [{ view: "NSTextField", parent: "NSView", detail: "18.0pt past the right" }]
The other containers are Spacer, Separator,
ScrollView, GroupBox, SplitView,
BlurView and Container.
Controls
The controls are Label, Button, Checkbox,
Switch, TextField, TextArea,
Slider, Select, Segmented,
Progress and ImageView. They report events through callbacks
such as onClick and onChange.
Tables
A Table takes an array of rows and a list of columns, and each column's
id is the key it reads from a row.
type Task = { title: string; done: boolean };
const table = new Table<Task>({
columns: [
{ id: "title", title: "Task", flex: true },
{ id: "done", title: "Done", width: 60, align: "center",
value: (t) => (t.done ? "✓" : "") },
],
rows: tasks,
grow: 1,
onSelect: (t) => (status.text = t ? t.title : "nothing selected"),
onDoubleClick: (t, i) => { t.done = !t.done; table.reloadRow(i); },
});
Menus
Application builds the standard menu bar, with its Quit and Edit menus, and
adds any items you give it:
const app = new Application({
name: "Tasks",
menu: {
file: [
{ title: "New Task", shortcut: "cmd+n", onClick: add },
{ separator: true, title: "" },
{ title: "Open…", shortcut: "cmd+o", onClick: open },
],
preferences: () => showSettings(),
menus: [{ title: "Task", items: [{ title: "Mark Done", shortcut: "cmd+d", onClick: markDone }] }],
},
});
Dialogs
Dialogs are sheets that slide down from a window and resolve a promise, because a classic
modal dialog would stop Bun's timers and promises while it waits. Pass
window, or alert and prompt fall back to that
blocking kind.
if (await confirm("Delete all tasks?", undefined, { destructive: true, window: win })) {
table.rows = [];
}
const name = await prompt("Add a person", { placeholder: "Full name", window: win });
const files = await openFile({ multiple: true, types: ["png", "jpg"], window: win });
const path = await saveFile({ defaultName: "export.json", window: win });
Raw AppKit
Every BunKit object has .native, the real Objective-C object behind it, and
objc reaches any class by name:
import { objc, Window } from "bunkit";
const win = new Window({ title: "Notes" });
win.native.setTitlebarAppearsTransparent_(true); // raw AppKit on any wrapper
objc.NSProcessInfo.processInfo().processName(); // any class, any method
Shipping an app
macOS gives an app its name, notifications and saved preferences only when it runs from an app bundle, and the bundler builds one:
bun run bundle examples/demo.ts --name "My App" --id com.example.myapp --icon icon.png
open "dist/My App.app"
The bundle is signed ad hoc by default, which is enough for your own Mac. To give the app
to other people, pass --sign "Developer ID Application: …" and then notarize
it yourself with notarytool. bun run bundle --help lists the
other flags.