Murasaki
Guides

Native APIs

What's exposed to your app from Murasaki's native surface today.

Murasaki's native window, menus, and OS integration are powered by @murasakijs/native — a self-authored Rust binding (tao/wry/muda) — so you never write Rust. This page covers what's actually reachable from your React app today.

Window & native menu bar

Window shape—size, title, route, visibility, permissions, and macOS vibrancy—is declared in murasaki.config.ts. window is the primary main window; windows contains labeled secondary windows. See Windows & permissions for the full model.

On macOS, the standard App/Edit/Window menu bar (About, Services, Hide, Quit, Edit, Window) is generated for you and localized from config.locales — no code required.

Context menu

The right-click context menu is declared with useContextMenu — a hook, not markup — and posts to the Rust side, which pops a real NSMenu / HMENU on macOS and Windows. See the dedicated Context Menu guide.

Dialogs, clipboard, notifications, shell, and window controls

Import renderer-safe native capabilities from murasaki/native. Every call is Promise-based and uses a request-correlated IPC message handled directly by the Rust host:

'use client'

import { app, appWindow, autostart, clipboard, dialog, globalShortcut, notification, secureStorage, shell, systemPermission, tray, windows } from 'murasaki/native'

const files = await dialog.openFile({
  multiple: true,
  filters: [{ name: 'Images', extensions: ['png', 'jpg'] }],
})
await clipboard.writeText(files.join('\n'))
await notification.show({ title: 'Files selected', body: `${files.length} files` })
await appWindow.setTitle('Import complete')
console.log(await appWindow.getLabel())
await windows.open('preview')
await shell.showItemInFolder(files[0])
APIOperations
appquit (graceful application shutdown), isElevated (read-only self-query)
autostartper-user login-startup status, enable, disable for packaged apps
dialogopenFile, openDirectory, saveFile, showMessage (native OS message box)
clipboardreadText, writeText, readImage, writeImage, writeHtml
notificationshow (resolves to a locally-generated id)
shellopenExternal (safe URL schemes only), showItemInFolder, trashItem, openPath, runElevated (Windows-only UAC launch)
secureStorageOS-backed get, set, delete for string values
systemPermissionmacOS status, request for camera, microphone, screen recording, accessibility, input monitoring, location, full disk access, photos, contacts, calendar, reminders, speech recognition, Bluetooth, Apple Events (automation), and local network
appWindowgetLabel, setTitle, setSize, minimize, toggleMaximize, show, hide, focus, close, setAlwaysOnTop, and state queries
windowsopen, list, show, hide, focus, close for declared labels
globalShortcutprocess-wide register, unregister, unregisterAll, onTriggered
traycreate, remove, setTooltip, setIcon, setMenu, onClick, onMenuItem

app.quit() and the root quit() helper both require app:quit; an unprivileged secondary renderer cannot terminate the whole application.

dialog.showMessage({ title?, message, level?, buttons? }) shows a native, main-thread message box (level defaults to 'info', buttons to 'ok') and resolves to whichever button was pressed ('ok' | 'cancel' | 'yes' | 'no'). title is bounded to 256 UTF-8 bytes and message to 4096, and both reject control characters (newlines are allowed in message only). It requires dialog:message.

clipboard.readImage() returns { width, height, pngBase64 } or null when the clipboard holds no image; clipboard.writeImage({ pngBase64 }) decodes and replaces the clipboard image (decoded pixels bounded to 64 MiB, each dimension to 16,384px). clipboard.writeHtml({ html, altText? }) writes HTML plus an optional plain-text fallback (html bounded to 1 MiB, altText to 64 KiB). These require clipboard:readImage, clipboard:writeImage, and clipboard:writeHtml respectively.

shell.trashItem(path) moves an existing file or directory to the OS trash or recycle bin; shell.openPath(path) opens an existing local file or directory with the OS default handler, like double-clicking it. Both require an absolute, non-traversing path that exists, and — like shell.showItemInFolder — their shell:trashItem/shell:openPath capabilities can be scoped to an allowed path subtree. shell.openPath additionally rejects anything that parses as a URL or a UNC network/device path; use shell.openExternal to open a URL instead. For a scoped grant, Murasaki resolves symlinks and requires both the requested path and its real target to remain inside the grant before opening it. showItemInFolder and trashItem resolve every parent directory and apply the same second scope check, while leaving a final symlink unresolved so the link itself remains the selected/deleted entry.

0.50 migration: quit() used to post an unpermissioned raw IPC message. Existing non-updater apps that call it must add app:quit to that window. Enabling the built-in updater grants it to the primary window only so the verified install/restart handshake remains backwards-compatible.

These APIs run in trusted renderer code, not directly in src/main.ts. The bridge accepts messages only from the app's exact origin and exposes a fixed, default-deny command allowlist. Grant only the commands each renderer uses through window.capabilities / windows[label].capabilities. Structured grants can scope shell URLs/paths, exact elevated executable/argv pairs, target window labels, OS permission names, secure-storage keys, and WebView cookie URLs with allow/deny rules. Other arguments remain command-level, so do not load untrusted remote content into a privileged app window.

Privilege elevation (Windows)

import { app, shell } from 'murasaki/native'

if (!(await app.isElevated())) {
  try {
    await shell.runElevated({
      executable: 'C:/Program Files/Example/updater.exe',
      args: ['--apply'],
    })
  } catch (error) {
    if (error instanceof Error && error.message.includes('cancelled by the user')) {
      // The user declined the UAC prompt — handle it gracefully.
    } else {
      throw error
    }
  }
}
murasaki.config.ts
export default defineConfig({
  window: {
    capabilities: [{
      permission: 'shell:runElevated',
      allow: { executions: [{
        executable: 'C:/Program Files/Example/updater.exe',
        args: ['--apply'],
      }] },
    }],
  },
})

app.isElevated() is a read-only, capability-gated (app:isElevated) query of whether the native host process is already running elevated. On Windows this reads the process token's elevation state; on macOS/Linux "elevated" means running as effective root — rare and discouraged for a GUI app, and supported here only as the closest analog. It works, and requires the capability, on every platform, and never fails: any underlying query error just resolves false, since a query that can't determine elevation has told an already-unprivileged renderer nothing it didn't already know.

shell.runElevated({ executable, args? }) launches executable under a fresh process through the Windows "Run as administrator" (UAC) consent prompt, and is Windows-only — every other platform rejects with an unsupported error, since macOS's SMJobBless/AuthorizationExecuteWithPrivileges are deprecated and Linux has no single equivalent mechanism. executable must be an absolute, non-traversing path to an existing file. A structured shell:runElevated grant matches the executable and the complete, ordered argument list as one exact executions entry; it never grants a path subtree with arbitrary arguments. Murasaki resolves symlinks and requires that exact real executable/argument pair to be granted too, preventing an in-scope link from redirecting elevation outside the grant. args (at most 64 entries, each at most 4096 UTF-8 bytes, and free of control characters) are passed directly to the elevated process — never through a shell — quoted using the same rules as CommandLineToArgvW. The call is fire-and-forget: it resolves once the elevated process has launched, not when it exits.

If the user declines the consent prompt, shell.runElevated rejects with an error whose message is exactly "elevation was cancelled by the user", so your app can handle a declined prompt differently from every other failure.

shell:runElevated is a powerful capability: granting it lets the renderer trigger a UAC prompt to run an executable elevated. Prefer a structured exact executable/argv grant; the string form is intentionally unrestricted for compatibility. Grant it only to windows that genuinely need a helper.

Login autostart

Packaged apps can let a user opt into launching the app at login without shipping a platform-specific helper:

import { autostart } from 'murasaki/native'

if ((await autostart.status()) === 'disabled') {
  await autostart.enable()
}

// Offer this from the same settings UI:
await autostart.disable()

Grant autostart:read to inspect the current registration and autostart:write to enable or disable it. Murasaki rejects all three calls under murasaki dev, preventing the temporary development Node executable from becoming a persistent login item. Registration is per user: a macOS LaunchAgent, the Windows current-user Run key, or an XDG Autostart desktop entry on Linux. status() returns 'enabled' only when the stored registration still matches the exact packaged executable; otherwise it returns 'disabled'.

murasaki.config.ts
export default defineConfig({
  window: {
    capabilities: ['autostart:read', 'autostart:write'],
  },
})

Call enable() only from an explicit user-facing setting. Users, endpoint management, and OS policy remain free to remove or disable the registration. The current macOS implementation uses a LaunchAgent and is not compatible with an App Sandbox build; sandboxed distribution needs a separately signed login-item helper and is not supported by this API yet.

Secure storage

Use secureStorage when trusted renderer code must persist a refresh token, license value, or another short string that should not be written to a plain file. macOS uses Keychain, Windows uses Credential Manager, and Linux uses the freedesktop.org Secret Service D-Bus API (gnome-keyring, KWallet, KeePassXC, and similar). Linux requires a running Secret Service provider — if none is reachable, every call fails with a structured error. Other targets return an explicit unsupported error. Murasaki never falls back to a plaintext file on any platform.

import { secureStorage } from 'murasaki/native'

await secureStorage.set('refresh-token', token)
const saved = await secureStorage.get('refresh-token') // string | null
await secureStorage.delete('refresh-token')            // absent is also OK

Grant each operation independently to only the windows that need it, and prefer an exact key or trailing-prefix scope:

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  window: {
    capabilities: [
      { permission: 'secureStorage:get', allow: { keys: ['account:*'] } },
      { permission: 'secureStorage:set', allow: { keys: ['account:*'] } },
      { permission: 'secureStorage:delete', allow: { keys: ['account:*'] } },
    ],
  },
})

Entries are namespaced from appId and key using SHA-256-derived identifiers, so keep appId stable across releases. Keys are non-empty strings up to 256 UTF-8 bytes; values are non-empty strings up to 2,048 UTF-8 bytes. NUL is rejected, the native IPC body remains capped at 256 KiB, and corrupt/non-UTF-8 stored data is returned as an error rather than silently decoded.

String capabilities remain key-unrestricted for compatibility. A structured keys list matches exactly unless an entry ends in one *, which matches a prefix. Keychain/Credential Manager protects data at rest from plaintext files; it does not protect a secret from XSS running inside an authorized scope. Keep secrets the renderer never needs in Node/server-only code.

Tray icon

Create one system tray icon from a client component. It uses config.icon by default, or an explicit 8-bit RGB/RGBA PNG. Every operation is independently permissioned:

import { tray } from 'murasaki/native'

await tray.create({
  tooltip: 'Sync is running',
  template: true,
  // macOS status-item menu / Windows system-tray menu
  menu: [
    { id: 'open', label: 'Open Murasaki' },
    { separator: true },
    { id: 'quit', label: 'Quit' },
  ],
  menuOnLeftClick: navigator.userAgent.includes('Mac OS X'),
  menuOnRightClick: true,
})
const unsubscribe = tray.onClick(({ button, double }) => {
  console.log({ button, double })
})
const unsubscribeMenu = tray.onMenuItem(async (id) => {
  if (id === 'open') await appWindow.show()
  if (id === 'quit') await app.quit()
})

await tray.setTooltip('Sync complete')
await tray.setIcon('/absolute/path/to/synced.png')
await tray.setMenu([{ id: 'open', label: 'Open Murasaki' }])
// Later: unsubscribe(); unsubscribeMenu(); await tray.remove()
murasaki.config.ts
export default defineConfig({
  // ...
  capabilities: [
    'tray:create',
    'tray:setTooltip',
    'tray:setIcon',
    'tray:setMenu',
    'tray:remove',
    'window:show',
    'app:quit',
  ],
})

template is macOS-specific. Creating a second tray icon replaces the first. Tray menu items are event-driven: every clickable item needs a unique id, and privileged actions such as quitting still pass through their own native capability. Closing the renderer that created the process-wide icon removes it.

Linux trays use libappindicator and need an AppIndicator host (on GNOME, install the AppIndicator/KStatusNotifierItem Shell extension). Tray menu clicks and dynamic icon/menu replacement work the same as macOS/Windows, but tray icon click/double-click events never fire on Linux — AppIndicator exposes no such signal, only "show the attached menu".

Global shortcuts

Trusted client components can register process-wide shortcuts on macOS, Windows, and Linux (X11/XWayland). Registration and removal are separate, deny-by-default capabilities:

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  window: {
    capabilities: [
      'globalShortcut:register',
      'globalShortcut:unregister',
    ],
  },
})
'use client'

import { useEffect } from 'react'
import { globalShortcut } from 'murasaki/native'

export function CaptureShortcut() {
  useEffect(() => {
    let active = true
    const unsubscribe = globalShortcut.onTriggered(({ id }) => {
      if (id === 'capture-region') startCapture()
    })

    void globalShortcut.register('CmdOrCtrl+Shift+K', 'capture-region')
      .catch((error) => {
        if (active) console.error('Shortcut unavailable', error)
      })

    return () => {
      active = false
      unsubscribe()
      void globalShortcut.unregister('capture-region')
    }
  }, [])

  return null
}

register(accelerator, id?) resolves to { id, accelerator }. The returned accelerator is platform-resolved and canonical; CmdOrCtrl becomes Command on macOS and Control on Windows/Linux. If id is omitted, that canonical accelerator is the id. unregisterAll() removes only registrations owned by the calling renderer, not another window's registrations.

Accelerators are bounded ASCII strings with one to four modifiers followed by one known key. Duplicate modifiers, key-only shortcuts, malformed/unknown keys, lifecycle/OS-reserved chords (for example Command+Q or Alt+F4 — Linux has no OS-level reserved list, since that depends on the desktop environment), duplicate ids, duplicate accelerators, and chords already owned by another application are rejected. At most 64 shortcuts may be live in one process. Shortcut ids are 1–128 characters and accept letters, numbers, ., _, :, -, and +.

Ownership follows the renderer window. Closing/disposal automatically releases its registrations; application shutdown releases all registrations. Trigger events are live-only and are sent only to the owner, with no replay after a reload.

Linux global shortcuts require X11 or XWayland (the underlying crate has no native Wayland backend). A pure-Wayland session (WAYLAND_DISPLAY set, no DISPLAY) rejects register with a structured unsupported error instead of silently registering nothing.

Parser, ownership, capability, macOS build, and Windows x64/arm64 build paths are automated. OS-level availability depends on other installed apps, the active keyboard layout, remote-desktop software, and OS-reserved bindings, so smoke-test the exact packaged shortcut on each supported OS before release.

System permissions

OS consent is separate from Murasaki's renderer capability allowlist. For a packaged macOS app, declare purpose text and optional launch-time prompts in config. Purpose strings are written into Info.plist; missing usage descriptions are rejected before a prompt can crash the app:

murasaki.config.ts
export default defineConfig({
  // ...
  systemPermissions: {
    macOS: {
      camera: {
        usageDescription: 'Use your camera for video calls.',
        requestOnLaunch: true,
      },
      microphone: {
        usageDescription: 'Use your microphone for voice calls.',
      },
      screenRecording: { requestOnLaunch: false },
      accessibility: { requestOnLaunch: false },
      inputMonitoring: { requestOnLaunch: false },
      location: {
        usageDescription: 'Show nearby points of interest.',
        mode: 'whenInUse',
      },
      fullDiskAccess: { requestOnLaunch: false },
      photos: { usageDescription: 'Attach a photo to your post.' },
      contacts: { usageDescription: 'Find your friends.' },
      calendar: { usageDescription: 'See your schedule.' },
      reminders: { usageDescription: 'See your reminders.' },
      speechRecognition: { usageDescription: 'Transcribe your voice memos.' },
      bluetooth: { usageDescription: 'Find nearby devices.' },
      appleEvents: { usageDescription: 'Automate another app on your behalf.' },
      localNetwork: { usageDescription: 'Discover devices on this network.' },
    },
  },
  capabilities: [
    'systemPermission:status',
    'systemPermission:request',
  ],
})

For contextual prompts from a client component:

const status = await systemPermission.status('microphone')
if (status === 'notDetermined') {
  await systemPermission.request('microphone')
}
KindShapeNotes
camerarequest-capableNSCameraUsageDescription.
microphonerequest-capableNSMicrophoneUsageDescription.
locationrequest-capableNSLocationWhenInUseUsageDescription (+ NSLocationAlwaysAndWhenInUseUsageDescription for mode: 'always').
photosrequest-capableNSPhotoLibraryUsageDescription. Read-write library access.
contactsrequest-capableNSContactsUsageDescription.
calendarrequest-capableNSCalendarsUsageDescription + NSCalendarsFullAccessUsageDescription (both always written — see below).
remindersrequest-capableNSRemindersUsageDescription + NSRemindersFullAccessUsageDescription (see below).
speechRecognitionrequest-capableNSSpeechRecognitionUsageDescription.
bluetoothrequest-capable, no explicit request callNSBluetoothAlwaysUsageDescription. See below.
screenRecordingprompt-style, no usage stringgranted/notGranted only.
accessibilityprompt-style, no usage stringgranted/notGranted only.
inputMonitoringprompt-style, no usage stringgranted/notGranted only.
fullDiskAccessguidance-onlySee below.
appleEventsguidance-only, declaration-onlyNSAppleEventsUsageDescription. See below.
localNetworkdeclaration-onlyNSLocalNetworkUsageDescription. See below.

Camera, microphone, location, photos, contacts, calendar, reminders, and speech recognition prompts complete asynchronously at the OS level, so request() may initially return notDetermined; query status() again when the app regains focus before enabling the protected feature.

requestOnLaunch applies to packaged macOS apps and every kind above except appleEvents/localNetwork (declaration-only — see below). Test TCC behavior from a packaged build because development runs under the terminal/Node host identity. On Windows, unpackaged desktop consent for the device-backed kinds is requested by the device API when it is used, not through a generic application-start prompt; Murasaki therefore reports unsupported instead of pretending permission was granted. Screen recording, accessibility, and input monitoring return notGranted when macOS cannot distinguish first-use from denial. All fifteen kinds are macOS-only — Windows and Linux have no OS-level equivalent of these TCC-gated prompts, so every call reports unsupported there instead of implying a grant that never happened.

location.usageDescription is required, same as camera/microphone; it is written to NSLocationWhenInUseUsageDescription. mode: 'always' additionally writes NSLocationAlwaysAndWhenInUseUsageDescription (Apple requires the when-in-use key present even for an always request) and requests always-authorization instead of when-in-use.

calendar/reminders request full access on macOS 14+ and fall back to the deprecated pre-14 EventKit API on older systems, checked against the running system at request time — not the build machine — so a single packaged app stays correct on both. Info.plist therefore always carries both the legacy usage key and the 14+ full-access key together, regardless of which system builds the app.

bluetooth has no explicit request call: CoreBluetooth determines consent implicitly the first time a central manager is created. status() reads CBManager.authorization — a class-level property that needs no live manager — so it is exactly as cheap as any other kind's status check; request() creates a manager purely to trigger that OS-side determination.

fullDiskAccess is guidance-only: macOS has no TCC request API for Full Disk Access, so request() can only open the Full Disk Access pane in System Settings for the user to grant it themselves — it never claims a grant happened. status() is a best-effort heuristic (it probes whether ~/Library/Application Support/com.apple.TCC/TCC.db is readable, since that file is itself gated by Full Disk Access) and can return unknown when the heuristic can't produce a confident answer, in addition to the usual granted/notGranted.

appleEvents and localNetwork are declaration-only — there is no requestOnLaunch field for either, and Murasaki's only role is writing their purpose string. Automation consent is granted per TARGET app and is only resolvable by actually attempting to send an Apple Event, so appleEvents's status() always reports unknown, and request() only opens System Settings' Automation pane as guidance (like fullDiskAccess above) rather than claiming a grant it can't verify. localNetwork has no query or request API at all — macOS prompts automatically the first time the app actually attempts local-network traffic — so both status() and request() are static unknown no-ops.

Entitlements and the App Sandbox

murasaki bundle --sign generates separate entitlement plists for the main app and bundled Node helper unless you supply sign.entitlements and/or sign.helperEntitlements:

  • Under Murasaki's default hardened-runtime-only posture (no App Sandbox), a signed app needs both its Info.plist purpose string and the matching host resource entitlement. Murasaki derives camera (com.apple.security.device.camera), microphone (com.apple.security.device.audio-input), location, photos, contacts, calendar/reminders, and Apple Events entitlements from systemPermissions.macOS. Bluetooth's device entitlement is App-Sandbox- only, and speech recognition has no Hardened Runtime resource entitlement, so those two use their purpose strings without an automatically generated entitlement here.
  • Node alone receives the JIT, unsigned-executable-memory, and disabled-library- validation hardened-runtime rights. .node add-ons are signed without executable entitlements. App-owned executable bundle.resources must be marked executable: true so they are signed before the outer app.
  • sign.appSandbox: true is currently rejected fail-closed. Apple's inherited sandbox helper requires an app-sandbox + inherit-only entitlement set, which is incompatible with the current embedded Node/JIT process. Murasaki does not claim App Sandbox support until that process architecture has a signed, notarized end-to-end path.
  • Custom main/helper files are used verbatim. A configured path that is missing, not a file, or not a valid plist fails the release build instead of silently falling back to generated privileges.

Built-in menu actions

An item's action can be one of the built-in <Action.*/> elements instead of a function — these run a native role (handled by the OS itself) or a small client-side behavior:

ActionBehavior
<Action.Copy />, <Action.Paste />, <Action.Cut />, <Action.SelectAll />, <Action.Undo />, <Action.Redo />Native OS edit roles
<Action.Quit />Native quit role
<Action.Reload />Reloads the window (location.reload())
<Action.Navigate to="/path" />Client-side navigation via the router
<Action.Run action={fn} />Runs a plain function (same as passing the function directly)

Auto-update

useUpdate() checks for, downloads, and installs updates — its check/download logic runs in Node and is reached over the same local HTTP server that serves the rest of the app (like Server Actions and API routes), not the IPC bridge the context menu and app menu use. <UpdateButton /> (also from murasaki, styled with @murasakijs/ui) is a ready-made button that wraps it:

import { useUpdate } from 'murasaki'

const { status, latest, check, download, install } = useUpdate()

status moves through idle → checking → available → downloading → ready (or not-available / error). Setup is two commands — see the dedicated Auto-update guide for the manifest format, the security model, and a GitHub Actions release workflow.

Structured scopes cover shell URLs/paths, exact elevated executable/argv pairs, target window labels, OS permission names, secure-storage keys, and WebView cookie URLs. Global shortcut accelerators, dialog defaults, and other arguments remain command-level. Check the platform feature status before designing around them.

Next

Improve this page on GitHub

On this page