Desktop Applications
On this page 54
Build native desktop applications with STX using @stacksjs/desktop - powered by Craft.
Overview
The @stacksjs/desktop package provides a complete API for building native desktop applications with STX templates. It uses Craft (Zig-based webview at ~/Code/craft) for native rendering.
Features
- Window Management - Create and control native windows
- System Tray - Build menubar applications
- Modals & Alerts - Native dialogs and notifications
- 35 UI Components - Complete component library
- Hot Reload - Development mode support
- 100% Test Coverage - 132 tests, 96.77% line coverage
- Craft-Powered - Native webview via Craft
Quick Start
Installation
@stacksjs/stx bundles the desktop package, so one dependency covers both the
templating and the native side:
bun add @stacksjs/stx
import { createWindow } from '@stacksjs/stx/desktop'
Importing @stacksjs/desktop directly works too, and is equivalent.
Native rendering needs the Craft binary, which ships through the pantry registry:
pantry install craft
createWindow resolves it from PATH. Point CRAFT_BINARY_PATH (or
setDesktopConfig({ craftBinaryPath })) at a local build to override that. A
packaged app carries its own copy inside the bundle, so shipped apps don't
require pantry on the user's machine.
Basic Usage
import { openDevWindow } from '@stacksjs/desktop'
// Open a development window
const window = await openDevWindow(3000, {
title: 'My STX App',
width: 1200,
height: 800,
darkMode: true,
hotReload: true
})
With STX CLI
Use the --native flag to automatically open a native window:
stx dev examples/homepage.stx --native
This will:
- Start the development server
- Open a native window pointing to
http://localhost:3000 - Enable hot reload for template changes
Window Management
Creating Windows
Basic Window
import { createWindow } from '@stacksjs/stx/desktop'
const window = await createWindow('http://localhost:3000', {
title: 'My Application',
width: 800,
height: 600,
resizable: true,
darkMode: false
})
Window with HTML Content
import { createWindowWithHTML } from '@stacksjs/stx/desktop'
const window = await createWindowWithHTML('<h1>Hello from STX Desktop!</h1>', {
title: 'Custom Window',
width: 600,
height: 400
})
Development Window
import { openDevWindow } from '@stacksjs/desktop'
// Opens window pointing to dev server
const window = await openDevWindow(3000, {
title: 'Development',
width: 1400,
height: 900,
darkMode: true,
hotReload: true
})
Window Options
interface WindowOptions {
title?: string // Window title
width?: number // Window width in pixels (default: 1200)
height?: number // Window height in pixels (default: 800)
resizable?: boolean // Allow resizing (default: true)
darkMode?: boolean // Dark mode support (default: false)
hotReload?: boolean // Reload the webview on file changes (default: false)
alwaysOnTop?: boolean // Float above other windows
frameless?: boolean // No title bar or window chrome
titlebarHidden?: boolean // Transparent titlebar, content extends under it
systemTray?: boolean // Show a menu bar icon for this window
hideDockIcon?: boolean // Menu bar only — no Dock icon
devTools?: boolean // Webview inspector (right-click → Inspect Element)
nativeSidebar?: boolean // Finder-style sidebar with vibrancy
sidebarWidth?: number // Sidebar width in pixels
}
Window Controls (Traffic Lights)
The close, minimise and zoom buttons belong to the platform. macOS draws real
ones on every window except a frameless one — including titlebarHidden and
nativeSidebar windows, where they float over the top-left of your page — so a
page that draws its own puts six circles in the corner, three of them dead.
SidebarHeader handles this for you. Its default, windowControls="auto",
draws mock lights in a browser and steps aside inside a desktop window, reading
the two variables Craft publishes before the document is parsed:
| Variable | In a Craft window | In a browser |
|---|---|---|
--craft-window-controls-replicas | none where real buttons exist | unset |
--craft-window-controls-width | the far edge of the real buttons, measured on the window — and 0 whenever they are not over your page | unset |
Craft measures the buttons rather than assuming them, and re-publishes on every resize, fullscreen transition and navigation, so a window with a titlebar of its own reports 0, and so does a fullscreen window whose titlebar has slid away.
Your own chrome can read them the same way, with no JavaScript:
.my-traffic-lights {
display: var(--craft-window-controls-replicas, flex);
}
.my-header {
padding-left: var(--craft-window-controls-width, 0px);
}
Pass windowControls="draw" to force replicas everywhere (a marketing page
imitating macOS), "native" to always reserve the room and draw nothing, or
"none" for a bare strip.
Checking Webview Availability
import { isWebviewAvailable } from '@stacksjs/desktop'
if (await isWebviewAvailable()) {
console.log('Webview is available!')
} else {
console.log('Webview not found. Install Craft.')
}
The Application Menu
The bar along the top of the screen. A Craft app that never sets one inherits AppKit's default: the app name, Edit, and Window, with nothing of your app in it and no keyboard shortcuts you chose.
import { menu, standardMenus } from '@stacksjs/desktop'
await menu.set({
menus: [
...standardMenus.leading('My App'),
{
label: 'View',
items: [
{ label: 'Reload', shortcut: 'cmd+r', onClick: () => location.reload() },
{ separator: true },
{ label: 'Enter Full Screen', role: 'fullscreen' },
],
},
standardMenus.window(),
],
})
Two rules that are not visible in the signature
Both produce a menubar that looks like your code failed to run, and neither raises an error.
set replaces the whole bar. There is no merge. Whatever you leave out is
gone — pass only a View menu and the app loses Copy and Paste.
The first menu becomes the application menu. AppKit takes it and retitles it
with the app name, so [Edit, View, Window] renders as My App, View, Window:
your Edit menu is still there, wearing the app's name, and its items are the
ones a person will look for under Edit and not find.
standardMenus.leading(name) exists to settle both at once. It returns the
application menu and Edit, in that order, so the rest of your menus follow in
the order you wrote them.
Standard menus
standardMenus.app('My App') // About, Hide, Hide Others, Show All, Quit
standardMenus.edit() // Undo, Redo, Cut, Copy, Paste, Select All
standardMenus.window() // Minimize, Zoom, Close Window
standardMenus.leading('My App') // [app(), edit()] — the two that must come first
Every item in these is declared by role rather than by a click handler, which
matters more than it looks. A role is performed by AppKit down the responder
chain, so Copy reaches whatever the user is actually focused on — including the
text field inside a native dialog, which a JavaScript handler cannot see. macOS
also recognises menus built from roles and adds to them on its own: Writing
Tools and Emoji & Symbols appear under Edit, Quit gains Keep Windows. Hand-roll
a "Copy" item with an onClick and you get none of that, and it does nothing in
a text field.
Handling picks
Give an item an onClick and set handles the wiring — the closure stays in
your code, the native side gets a generated id, and a later set replaces the
previous wiring rather than stacking a second listener on it.
{ label: 'Rescan', shortcut: 'cmd+r', onClick: () => rescan() }
For a single handler over many items — dispatching to a router, say — set your
own ids and listen centrally instead. The two compose; an item with an onClick
is simply also dispatched to its closure.
const stop = menu.onAction((event) => {
if (event.id.startsWith('nav.')) router.go(event.id.slice(4))
})
Knowing whether it applied
set resolves true when the menu reached the native side and false when
there is no bridge — in a browser tab, or a dev server opened outside Craft.
if (!(await menu.set(appMenu)))
console.info('not running under Craft; no application menu')
Without that return value, "this is a browser" and "this payload was wrong" look identical from the calling side, and both look like nothing happened.
System Tray
Creating a System Tray
import { createSystemTray } from '@stacksjs/desktop'
const tray = createSystemTray({
icon: '/path/to/icon.png',
title: 'My App',
tooltip: 'My Application'
})
Creating a Menubar App
import { createMenubar } from '@stacksjs/desktop'
const menubar = createMenubar({
icon: '/path/to/menubar-icon.png',
width: 400,
height: 500,
window: {
title: 'Menubar App',
url: 'http://localhost:3000'
}
})
System Tray Options
interface SystemTrayOptions {
icon: string // Path to tray icon
title?: string // Tray title
tooltip?: string // Tooltip text
}
interface MenubarOptions {
icon: string // Menubar icon path
width: number // Popup width
height: number // Popup height
window: WindowOptions // Window configuration
}
Modals & Dialogs
Generic Modal
import { showModal } from '@stacksjs/desktop'
const result = await showModal({
title: 'Confirm Action',
message: 'Are you sure you want to continue?',
buttons: ['Cancel', 'Continue'],
defaultButton: 1
})
if (result === 1) {
console.log('User clicked Continue')
}
Specialized Modals
import {
showErrorModal,
showInfoModal,
showSuccessModal,
showWarningModal,
showQuestionModal
} from '@stacksjs/desktop'
// Error modal
await showErrorModal({
title: 'Error',
message: 'Something went wrong!'
})
// Info modal
await showInfoModal({
title: 'Information',
message: 'Process completed successfully.'
})
// Success modal
await showSuccessModal({
title: 'Success',
message: 'File saved successfully!'
})
// Warning modal
await showWarningModal({
title: 'Warning',
message: 'This action cannot be undone.'
})
// Question modal with yes/no
const answer = await showQuestionModal({
title: 'Confirm',
message: 'Delete this file?'
})
Modal Options
interface ModalOptions {
title: string // Modal title
message: string // Modal message
buttons?: string[] // Button labels
defaultButton?: number // Default button index
}
Alerts & Toasts
Generic Alert
import { showAlert } from '@stacksjs/desktop'
await showAlert({
title: 'Alert',
message: 'This is an alert message.'
})
Toast Notifications
import {
showToast,
showSuccessToast,
showErrorToast,
showWarningToast,
showInfoToast
} from '@stacksjs/desktop'
// Generic toast
showToast({
message: 'Task completed',
duration: 3000
})
// Success toast
showSuccessToast({
message: 'File saved successfully!'
})
// Error toast
showErrorToast({
message: 'Failed to save file'
})
// Warning toast
showWarningToast({
message: 'Disk space running low'
})
// Info toast
showInfoToast({
message: 'Update available'
})
Alert Options
interface AlertOptions {
title?: string // Alert title
message: string // Alert message
}
interface ToastOptions {
message: string // Toast message
duration?: number // Duration in milliseconds (default: 3000)
}
UI Components
The desktop package provides 35 UI components for building native interfaces. Currently, 3 are fully implemented, with 32 as placeholders ready for native implementation.
Implemented Components
Button Component
import { createButton } from '@stacksjs/desktop'
const button = createButton({
label: 'Click Me',
variant: 'primary',
onClick: () => {
console.log('Button clicked!')
}
})
Checkbox Component
import { createCheckbox } from '@stacksjs/desktop'
const checkbox = createCheckbox({
label: 'Accept Terms',
checked: false,
onChange: (checked) => {
console.log('Checkbox changed:', checked)
}
})
Text Input Component
import { createTextInput } from '@stacksjs/desktop'
const input = createTextInput({
placeholder: 'Enter your name',
value: '',
onChange: (value) => {
console.log('Input changed:', value)
}
})
Available Components
All 35 components are defined and ready for implementation:
import { AVAILABLE_COMPONENTS } from '@stacksjs/desktop'
console.log(AVAILABLE_COMPONENTS)
// ['Button', 'Checkbox', 'TextInput', 'Select', 'Radio',
// 'Switch', 'Slider', 'ProgressBar', 'Spinner', 'Avatar',
// 'Badge', 'Card', 'Divider', 'Image', 'Icon', 'Tabs',
// 'Accordion', 'Modal', 'Drawer', 'Tooltip', 'Popover',
// 'DropdownMenu', 'ContextMenu', 'Alert', 'Toast', 'Dialog',
// 'DatePicker', 'TimePicker', 'ColorPicker', 'FileUpload',
// 'Table', 'List', 'Tree', 'Timeline', 'Stepper']
See the Component source code for complete component documentation.
Integration with Craft
The desktop package uses Craft for native webview rendering.
Current Status
- API Complete: All APIs are defined with TypeScript types
- Tests: 100% test coverage (132 tests, 96.77% line coverage)
- Documentation: Complete API documentation
- Implementation: Powered by Craft (~/Code/craft)
Integration Points
With Craft integrated, the desktop package:
- Detects Native Webview:
isWebviewAvailable()checks for Craft - Creates Native Windows:
createWindow()uses native webview - System Integration: System tray and native modals work
- Component Rendering: UI components render natively
Example Usage
import { openDevWindow } from '@stacksjs/desktop'
// Desktop package uses Craft under the hood
const window = await openDevWindow(3000, {
title: 'STX with Craft',
width: 1200,
height: 800
})
// Creates a native webview window
Development
Running Tests
The desktop package has comprehensive test coverage:
cd packages/desktop
bun test # Run all tests
bun test --coverage # With coverage report
Test Coverage:
- 132 total tests
- 96.77% line coverage
- All APIs tested
- Edge cases covered
Example Applications
The desktop package includes working examples:
# Simple window example
bun packages/desktop/examples/basic-window.ts
# System tray example
bun packages/desktop/examples/system-tray.ts
# Modals and alerts example
bun packages/desktop/examples/modals.ts
Architecture
Webview-Agnostic Design
The desktop package is designed to work with any native webview implementation:
┌─────────────────────────────────────┐
│ @stacksjs/desktop (TypeScript API) │
│ - Window Management │
│ - System Tray │
│ - Modals & Alerts │
│ - UI Components │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Craft (Native Webview) │
│ - Zig-based webview │
│ - GTK/WebKit/WebView2 │
│ - Native APIs │
└─────────────────────────────────────┘
Type Safety
All APIs are fully typed with TypeScript:
// packages/desktop/src/types.ts
export interface WindowOptions {
title: string
width: number
height: number
url?: string
html?: string
resizable?: boolean
darkMode?: boolean
hotReload?: boolean
}
export interface Window {
close(): Promise<void>
minimize(): Promise<void>
maximize(): Promise<void>
restore(): Promise<void>
setTitle(title: string): Promise<void>
// ... more methods
}
Best Practices
1. Development vs Production
// Use openDevWindow in development
if (process.env.NODE_ENV === 'development') {
await openDevWindow(3000, {
title: 'Dev Mode',
hotReload: true
})
} else {
// Use createWindow with bundled HTML in production
await createWindowWithHTML({
title: 'My App',
html: await Bun.file('dist/index.html').text()
})
}
2. Error Handling
import { createWindow, isWebviewAvailable } from '@stacksjs/desktop'
// Check availability first
if (!(await isWebviewAvailable())) {
console.error('Native webview not available')
process.exit(1)
}
// Handle window creation errors
try {
const window = await createWindow({
title: 'My App',
width: 800,
height: 600
})
} catch (error) {
console.error('Failed to create window:', error)
}
3. Resource Management
// Clean up resources on exit
const window = await createWindow(options)
process.on('SIGINT', async () => {
await window.close()
process.exit(0)
})
API Reference
See the Desktop source code for detailed documentation of all methods and types.
Next Steps
- Review Desktop source code
- Review Component implementation
- Check out Examples
- Learn about STX CLI
- Understand Configuration
Bundled local backends (macOS)
A desktop utility that processes local data can add backend: { entry: 'src/api.ts', maxRequestBodySize: 33554432 } to buildNative configuration.
The entry's default export accepts a Request and returns a Response (or
null for an unhandled route). STX compiles the backend and embedded UI into
one Bun helper behind the native Craft application process. End users need neither
Bun nor a running development server.
The host binds an ephemeral port on 127.0.0.1, bootstraps a single-use session,
and requires an HttpOnly, SameSite cookie for the UI and API. Host and Origin
checks reject foreign pages and DNS rebinding. Closing Craft stops the server.
Contents/MacOS/stx-backend --serve exposes the same authenticated host for CLI
clients and integration testing, printing its single-use session URL.
Backend paths resolve from the build working directory. These bundles currently
support macOS; other targets fail explicitly. Package the complete .app
through Craft's macos.appBundlePath option so resources and metadata survive.
Craft must advertise bundleBackend through --capabilities. Older runtimes
fail the build with an upgrade instruction. Craft starts the bundled helper and
holds its lifetime pipe open; the helper exits when the native process closes,
including AppKit termination. The session URL never leaves loopback.
For Mac App Store distribution, sign Craft with the app sandbox entitlements
and the helper with sandbox inheritance (plus any backend runtime entitlements).
Use macos.helperEntitlements when packaging with Craft. app.getInfo() from
@stacksjs/desktop/browser reports sandboxed; use native file and folder
pickers to obtain access before reading inputs or saving copies to a directory.
No platform-specific source is needed in the application.