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:

  1. Start the development server
  2. Open a native window pointing to http://localhost:3000
  3. 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:

VariableIn a Craft windowIn a browser
--craft-window-controls-replicasnone where real buttons existunset
--craft-window-controls-widththe far edge of the real buttons, measured on the window — and 0 whenever they are not over your pageunset

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?'
})
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:

  1. Detects Native Webview: isWebviewAvailable() checks for Craft
  2. Creates Native Windows: createWindow() uses native webview
  3. System Integration: System tray and native modals work
  4. 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

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.