Scarlet Industries

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:

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); },
});

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.