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])| API | Operations |
|---|---|
app | quit (graceful application shutdown), isElevated (read-only self-query) |
autostart | per-user login-startup status, enable, disable for packaged apps |
dialog | openFile, openDirectory, saveFile, showMessage (native OS message box) |
clipboard | readText, writeText, readImage, writeImage, writeHtml |
notification | show (resolves to a locally-generated id) |
shell | openExternal (safe URL schemes only), showItemInFolder, trashItem, openPath, runElevated (Windows-only UAC launch) |
secureStorage | OS-backed get, set, delete for string values |
systemPermission | macOS 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 |
appWindow | getLabel, setTitle, setSize, minimize, toggleMaximize, show, hide, focus, close, setAlwaysOnTop, and state queries |
windows | open, list, show, hide, focus, close for declared labels |
globalShortcut | process-wide register, unregister, unregisterAll, onTriggered |
tray | create, 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
}
}
}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'.
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 OKGrant each operation independently to only the windows that need it, and prefer an exact key or trailing-prefix scope:
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()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:
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:
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')
}| Kind | Shape | Notes |
|---|---|---|
camera | request-capable | NSCameraUsageDescription. |
microphone | request-capable | NSMicrophoneUsageDescription. |
location | request-capable | NSLocationWhenInUseUsageDescription (+ NSLocationAlwaysAndWhenInUseUsageDescription for mode: 'always'). |
photos | request-capable | NSPhotoLibraryUsageDescription. Read-write library access. |
contacts | request-capable | NSContactsUsageDescription. |
calendar | request-capable | NSCalendarsUsageDescription + NSCalendarsFullAccessUsageDescription (both always written — see below). |
reminders | request-capable | NSRemindersUsageDescription + NSRemindersFullAccessUsageDescription (see below). |
speechRecognition | request-capable | NSSpeechRecognitionUsageDescription. |
bluetooth | request-capable, no explicit request call | NSBluetoothAlwaysUsageDescription. See below. |
screenRecording | prompt-style, no usage string | granted/notGranted only. |
accessibility | prompt-style, no usage string | granted/notGranted only. |
inputMonitoring | prompt-style, no usage string | granted/notGranted only. |
fullDiskAccess | guidance-only | See below. |
appleEvents | guidance-only, declaration-only | NSAppleEventsUsageDescription. See below. |
localNetwork | declaration-only | NSLocalNetworkUsageDescription. 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 fromsystemPermissions.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.
.nodeadd-ons are signed without executable entitlements. App-owned executablebundle.resourcesmust be markedexecutable: trueso they are signed before the outer app. sign.appSandbox: trueis currently rejected fail-closed. Apple's inherited sandbox helper requires anapp-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:
| Action | Behavior |
|---|---|
<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.