- name
- stacks-desktop
- description
- Use when building or publishing desktop applications with Stacks - Craft native windows, local launchers, helper processes, packaging, capability probes, or Mac App Store delivery. Covers @stacksjs/desktop-build and the browser bridge boundary.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript
- allowed-tools
- Read Edit Write Bash Grep Glob
# Stacks Desktop
## Key Paths
- Core package: `storage/framework/core/desktop/src/`
- Source: `storage/framework/core/desktop/src/index.ts`
- System tray views: `storage/framework/defaults/views/system-tray/`
- System tray layouts: `storage/framework/defaults/resources/layouts/`
- Build/host package: `@stacksjs/desktop-build`
- Browser bridge package: `@stacksjs/desktop/browser` (from the STX ecosystem)
## Runtime
For offline native projects (`stacks.type: native`), Buddy compiles the app and
Pantry owns runtime provisioning and distribution. Declare immutable runtime
pins, entitlements, native checks and publishing targets in `pantry.json.native`.
Buddy uses `ts-pantry/native` to provision Craft with its locked compiler and
first-party pins and to prepare signing inputs. Keep `craft.config.ts` for app
metadata and UI. Do not scaffold app-specific runtime scripts, entitlement XML,
keychain scripts, notarization commands or store-upload workflows. Use Pantry's
versioned `.github/workflows/native-app.yml` reusable workflow for checks and
releases. New/adopted native apps also use Buddy-encrypted `.env.production`,
with `.env.keys` ignored and only `DOTENV_PRIVATE_KEY_PRODUCTION` passed to
release Actions. Native `buddy release --bump patch|minor|major` delegates
qualification and versioning to Pantry; the tag invokes its delivery workflow.
`CRAFT_BIN` remains a local development override; release pipelines
require the declared immutable runtime.
Stacks desktop applications use the native [Craft](https://github.com/home-lang/craft)
runtime. `buddy dev:desktop` hosts the dashboard/application and opens it in a
Craft window. `buddy build:desktop` compiles a small Stacks launcher and places
the launcher, pinned Craft runtime, manifest, provenance, and checksums in
`storage/framework/desktop-dist`.
Craft is normally installed through pantry and resolved from PATH. Set
`CRAFT_BIN` to an explicit compiled native binary in CI or local Craft
development. When testing local SDK source as well, set `CRAFT_SDK_SRC` to its
`src/index.ts` entry. Set `STACKS_NO_NATIVE=1` only when a deliberately web-only
dashboard process is required, such as headless browser QA. A missing
`CRAFT_BIN` is an error and is never treated as a headless flag.
Native development windows load the dashboard through its loopback HTTP
origin. Keep pretty HTTPS domains for browser access. WKWebView does not assume
that a developer's local certificate authority is trusted, and a failed TLS
navigation otherwise appears as a blank native window.
## API
Two packages carry "desktop" in their name. `@stacksjs/desktop-build` (this
repo, `storage/framework/core/desktop`) holds the build and launch helpers
below. `@stacksjs/desktop` (the stx repo) wraps the Craft bridge for code
running inside the window; import it from `@stacksjs/desktop/browser` there,
since its root entry also carries host-side modules that pull in Node builtins
and fail a browser bundle. Attic (`~/Code/Apps/attic`) uses it for native
menus, context menus and alerts.
```typescript
import { openDevWindow } from '@stacksjs/desktop-build'
import type { Desktop, OpenDevWindowOptions } from '@stacksjs/desktop-build'
interface OpenDevWindowOptions {
title?: string
width?: number
height?: number
darkMode?: boolean
hotReload?: boolean
nativeSidebar?: boolean
sidebarWidth?: number
sidebarConfig?: unknown
}
async function openDevWindow(port: number, options?: OpenDevWindowOptions): Promise<boolean>
```
### Desktop Interface
```typescript
interface Desktop {
app: unknown
core: unknown
dpi: unknown
event: unknown
image: unknown
menu: unknown
mocks: unknown
path: unknown
tray: unknown
webview: unknown
webviewWindow: unknown
window: unknown
}
```
## Local-first apps: owning the launcher
### Model-backed offline native apps
An app created with `buddy new <name> --native` can use `craft.config.ts`'s
`backend: { entry: 'app/Desktop/api.ts' }` to bundle a Bun backend. The native
host owns an authenticated ephemeral loopback session and the backend's lifetime.
The binary does not need an installed Bun, external server or source files.
Define models under `app/Models` with `defineModel` from `@stacksjs/orm/define-model`.
In the backend, import those models explicitly and use `openNativeDatabase` and
`createNativeModelApi` from `@stacksjs/orm/native`. Pass an absolute SQLite path
inside Application Support and an explicit model registry. The same query-builder
migration planner derives the schema and atomically records its snapshot in the
database. Additive upgrades preserve rows; destructive changes are refused.
CRUD follows `traits.useApi.uri` and its explicit `routes` list. Native models
declare `middleware: []` because the transport owns session authorization.
`createNativeModelApi` requires an `authorize(request, model, ability)` callback
on every request. Only return true unconditionally when the handler runs solely
behind stx's authenticated native backend. Never expose that handler through an
unprotected HTTP server. Web middleware declarations are refused by this native
adapter instead of silently skipped. Writes use the model's validation, fillable
allowlist and hidden-field serialization; index requests have bounded pagination.
The launcher Stacks compiles opens a Craft window on the URL in `desktop.json`,
which is what a hosted Stacks application wants. An app whose subject is the
machine it runs on - a disk cleaner, a log viewer, a device tool - has no such
URL: it starts something locally and opens a window on that, on a port it does
not know until launch.
Write `app/Desktop/launcher.ts` and `build:desktop` compiles that instead, the
same way anything under `app/` overrides its framework default. `DESKTOP_URL`
and `APP_URL` then become optional, because the launcher decides what to open.
```ts
// app/Desktop/launcher.ts - compiled to Contents/MacOS/<AppName>
import { dirname, join } from 'node:path'
const macos = dirname(process.execPath) // siblings live here
const server = Bun.spawn([join(macos, 'my-agent')], { stdout: 'pipe' })
const port = await readPortFrom(server.stdout) // the agent picks a free one
const craft = Bun.spawn([
join(macos, 'craft-runtime'),
`http://127.0.0.1:${port}`,
'--title', 'My App',
], { stdout: 'inherit' })
process.exit(await craft.exited)
```
Two things follow from declaring your own launcher:
- **Every file `build:desktop` leaves in `storage/framework/desktop-dist` is
copied into `Contents/MacOS`.** Compile the sibling binaries your launcher
spawns into that directory and they ship with it.
- **`app/Desktop/Resources/` is copied into `Contents/Resources`.** A
prerendered UI, a schema, seed data - a local-first app has a payload, and
this is where it goes.
`build:dmg` also narrows App Transport Security for these bundles: an exception
for `127.0.0.1` rather than `NSAllowsArbitraryLoads`, which would additionally
permit every unencrypted host on the internet.
### Info.plist entries
`app/Desktop/Info.plist.json` is merged into the generated `Info.plist`. The
entries that matter most are the `NS*UsageDescription` strings - they are the
sentences a person reads when macOS asks whether your app may look in their
Downloads folder or drive Finder, and without them the prompt is generic or the
request is refused outright.
```json
{
"LSApplicationCategoryType": "public.app-category.utilities",
"NSDownloadsFolderUsageDescription": "MyApp scans your Downloads for large files.",
"NSAppleEventsUsageDescription": "MyApp asks Finder to move items to the Trash so deletions stay recoverable."
}
```
JSON, not XML: a malformed plist produces a bundle macOS silently refuses to
launch, which surfaces long after the build reported success. Strings, numbers,
booleans, arrays, and nested objects all render.
Keys the bundle must own - `CFBundleIdentifier`, `CFBundleExecutable`,
`CFBundleVersion`, and the rest of the identity - are ignored with a note.
Rewriting those produces a bundle that does not match what was signed.
Inside the bundle the launcher is named after the app, not `stacks-desktop`.
macOS names the *process* in every permission prompt, so a launcher keeping the
framework's build name asks "stacks-desktop would like to access files in your
Downloads folder" - which reads like something to refuse.
Application data belongs in `~/Library/Application Support/<AppName>`, never
inside the bundle - `/Applications` is not writable by the user, and the bundle
is replaced wholesale on update.
## Helper processes: one binary, not three
An app that owns its launcher usually needs more than the launcher - an agent
serving on loopback, a worker doing something slow out of process. Compile each
as its own binary and the bundle gets very large very quietly.
`bun build --compile` embeds the entire Bun runtime in **every** executable it
writes. `console.log("hi")` measures **60.5 MB**. So three binaries means three
copies of the same runtime, and nothing says so - the bundle is simply big, and
a big desktop app looks unremarkable. One real app shipped 230 MB this way, of
which about 180 MB was the runtime repeated; its own code plus the native Craft
runtime came to under 40 MB.
Compile **one** binary and dispatch on a subcommand. The helpers stay separate
*processes* - which is what actually matters for isolation and for killing one
that wedges - they just stop being separate *files*.
```ts
// app/Desktop/launcher.ts - before anything else at module scope
const subcommand = process.argv[2]
if (subcommand === 'agent' || subcommand === 'scan') {
if (subcommand === 'agent') {
const { runAgent } = await import('./server')
await runAgent()
}
else {
const { runScannerCli } = await import('../Workers/scan')
await runScannerCli(process.argv[3] ?? '')
}
// Park. Everything below is the launcher, and a subcommand reaching it would
// spawn a second agent and open a second window.
await new Promise(() => {})
}
```
Then spawn `process.execPath` instead of a sibling path:
```ts
const agent = Bun.spawn([process.execPath, 'agent'], { stdout: 'pipe' })
```
Two things that bite, both silently:
- **The dispatch must come first.** A launcher's body is top-level code that
starts a server and opens a window as a side effect of loading.
- **Do not `process.exit()` after starting a server.** A `runAgent()` that
returns once `Bun.serve` is listening has not finished - `Bun.serve` is what
holds the process open. Exiting after it kills the agent one line after it
printed the port the launcher is still waiting for, and the launcher reports
a timeout that says nothing about the cause.
`buddy build:dmg` warns when a finished bundle contains more than one
Bun-compiled executable, naming them and what the repetition costs.
## Native macOS apps (`config/desktop.ts`)
A project whose app is a client of an HTTP API (a hosted service, not a local server) declares it in
`config/desktop.ts`, the way `config/mobile.ts` declares a phone app, and `buddy build:desktop` builds a
native AppKit app from its stx screens: no web view, no launcher.
```ts
import type { DesktopConfig } from '@stacksjs/types'
export default {
macos: {
appName: 'Library',
bundleId: 'com.example.library',
teamId: '3JJRNQW6B7', // not a secret
url: process.env.LIBRARY_API_URL || 'http://127.0.0.1:3408',
nativeScreens: ['Main', 'Settings'], // resources/native/<name>.stx
shareStorage: { auth_token: 'auth.token' },
window: { width: 1180, height: 760, minWidth: 860, minHeight: 540 },
icon: 'resources/native/icon/AppIcon-1024.png',
privacy: { tracking: false, collectedDataTypes: [] },
},
} satisfies DesktopConfig
```
- `buddy build:desktop` compiles the screens (`compileNativeBundle`), builds Craft's AppKit host
(`craft-native/macos`, cached per Craft revision), lays out `<App>.app` and signs it ad hoc. `buddy dev:desktop`
does the same and opens it. Output: `storage/framework/desktop/macos/`.
- The toolchain is not the app's business: Craft resolves the pinned Zig through Pantry and fetches and builds the pinned
zig-js into `~/.cache/craft/toolchain` (`CRAFT_TOOLCHAIN_DIR` moves it, `CRAFT_ZIG` overrides the compiler).
- The host reads `Contents/Resources/craft-app.json` (written by the build) when Finder launches it, so the app has no
arguments. The API origin is `url`, and `craft.secureStorage` is the Keychain (`craft.session` is the `auth.token` item).
- `buddy desktop:apple:package` and `publish` build the same bundle for the Mac App Store (sandbox, network client,
application identifier, Keychain group, embedded profile). Name, bundle id, team, version, category and icon come
from `config/desktop.ts`; only the credentials (identities, profile, API key) are Apple variables. `--universal` is not
supported for native apps yet.
- A `macos` section in `config/desktop.ts` switches `build:desktop` and `dev:desktop` to this path; without one they
build the Craft web-view app as before.
- One set of views can be both: the web server resolves the native primitives (`View`, `Text`, `ScrollView`, ...) as
components of last resort (stx config `nativePrimitives`), so `resources/views` pages written with them are also native
screens. Menus, windows and the Keychain are the screen's `craft.desktop` and `craft.secureStorage`.
### An app that runs its own API (`macos.agent`)
A local-first app (a browser, an inbox, anything whose data is this Mac) runs the project's routes inside the
app instead of talking to a hosted server. `buddy new <name> --desktop` scaffolds one (combinable with
`--minimal`, and happy to scaffold into a directory that holds only `.git`):
```ts
export default {
macos: {
appName: 'Browser',
bundleId: 'com.example.browser',
agent: true, // or { port, entry, appRoutes, staticDir }
nativeScreens: ['Main'],
webkit: true, // link WebKit into the host, for <WebView>
defaultBrowser: true, // http/https URL types, HTML/XHTML/webarchive/PDF document types
permissions: { camera: true, microphone: 'For calls in web pages.', location: true, downloads: true, printing: true },
dockMenu: [{ title: 'New Window', action: 'new-window' }],
},
} satisfies DesktopConfig
```
- The agent (`@stacksjs/desktop-build` `startDesktopAgent`, `src/agent.ts`) is `Bun.serve` on 127.0.0.1, on the
port remembered in `~/Library/Application Support/<App>/agent.port` (any free port when it is taken). The host fetches
`GET /native/session` and gets an HttpOnly, SameSite=Strict cookie holding a token minted for this launch; every other
request needs that cookie and a Host header of `127.0.0.1:<port>` or `localhost:<port>`, so a DNS-rebinding page
cannot call it. `url` is not needed.
- It serves the app's routes in process: `routes/` as `app/Routes.ts` mounts them, plus the agent's own module
(`app/Desktop/Agent.ts` by convention, `agent.entry` otherwise). Routes the agent module adds are not prefixed. A
default-exported `(request) => Response` there replaces the router entirely.
- `buddy dev:desktop` runs the launcher from source in the project (`bun`, no bundle), which boots the routes as
`buddy dev:api` does. `buddy build:desktop` compiles the launcher into `<App>.app/Contents/MacOS/<App>`, with the host
beside it as `craft-runtime` and `desktop-agent.json` in Resources. A compiled binary cannot import route files off
disk (the router refuses), so the build imports the registry's route files, the agent module and every
`'Actions/...'` they name statically (`provideActions`); what they import travels inside the app.
- The launcher (`src/agent-launcher.ts`) holds a single-instance lock (`agent.pid`), logs to
`~/Library/Logs/<App>.log` when launched without a terminal, and quits with the window.
- Entitlements: the Bun executable gets `allow-jit`, `allow-unsigned-executable-memory` and
`disable-library-validation`; in the sandbox the agent adds `network.server`, and the host is signed with only
`app-sandbox` and `inherit`. `permissions` adds each device entitlement and its `NS*UsageDescription` (an explicit
`usageDescriptions` entry wins).
- `webkit` needs a craft-native whose `buildMacOSNative` takes `webkit`; the build checks Craft's source and fails by
name rather than building a host with blank web views. `dockMenu` reaches the host through `craft-app.json`.
## CLI Commands
```bash
buddy new <name> --desktop # a Stacks app that is also a native Mac app, API inside (macos.agent)
buddy dev:desktop # start the desktop development server
buddy build:desktop # build the launcher + Craft runtime payload
buddy desktop:apple:init # generate the reusable GitHub Actions caller
buddy desktop:apple:doctor # validate Apple tooling, identities, profile, and API key
buddy desktop:apple:package # build, sandbox, sign, and create a Store .pkg
buddy desktop:apple:publish # validate or upload the package to App Store Connect
buddy desktop:probe # measure what a Craft window offers interactive/game content
```
`desktop:probe` opens a visible Craft window (not `--headless`, which stops
requestAnimationFrame) and measures WebGL2/WebGPU, frame pacing under load,
input and audio latency, the Gamepad API, fullscreen and pointer lock.
`--browser[=Safari]` runs the same page in a browser for comparison, and
`--record` writes the result into `storage/framework/core/desktop/src/probe/results/`,
which `buddy docs:capabilities` publishes in `docs/features/capabilities.md`.
Probes that need a real click or key press are recorded as
`requires-interaction`, never as a number. Stacks ships no game engine,
physics or asset pipeline; this only says what an engine someone else wrote
would get.
## Mac App Store
Run `buddy desktop:apple:init`, then configure the repository variables and
secrets named by the generated workflow. The reusable Stacks workflow builds
Craft from an immutable revision, imports signing material into an ephemeral
View on GitHub